From 730d7c755dfe7abfa716711607cb44ef2afd1eaa Mon Sep 17 00:00:00 2001 From: oujinsai Date: Fri, 20 Mar 2026 23:10:32 +0800 Subject: [PATCH 1/2] refactor(commands): establish single-layer command pipeline Move command execution into a single handlers layer and keep api.lua as a thin action map. This removes the split handlers/usecases execution path so command parsing, dispatch, and execution boundaries are explicit and reviewable. --- lua/opencode/api.lua | 1708 +---------------- .../commands/completion_providers.lua | 24 + lua/opencode/commands/dispatch.lua | 194 ++ lua/opencode/commands/handlers.lua | 104 + lua/opencode/commands/handlers/agent.lua | 91 + lua/opencode/commands/handlers/diff.lua | 269 +++ lua/opencode/commands/handlers/permission.lua | 95 + lua/opencode/commands/handlers/session.lua | 320 +++ lua/opencode/commands/handlers/window.lua | 198 ++ lua/opencode/commands/handlers/workflow.lua | 695 +++++++ lua/opencode/commands/init.lua | 77 + lua/opencode/commands/parse.lua | 106 + lua/opencode/commands/router.lua | 147 ++ lua/opencode/commands/slash.lua | 76 + lua/opencode/config.lua | 8 + lua/opencode/init.lua | 3 +- lua/opencode/keymap.lua | 170 +- lua/opencode/registry/builtin_commands.lua | 210 ++ lua/opencode/registry/extensions/commands.lua | 5 + lua/opencode/registry/extensions/slash.lua | 30 + lua/opencode/registry/init.lua | 175 ++ lua/opencode/registry/loader.lua | 84 + 22 files changed, 3171 insertions(+), 1618 deletions(-) create mode 100644 lua/opencode/commands/completion_providers.lua create mode 100644 lua/opencode/commands/dispatch.lua create mode 100644 lua/opencode/commands/handlers.lua create mode 100644 lua/opencode/commands/handlers/agent.lua create mode 100644 lua/opencode/commands/handlers/diff.lua create mode 100644 lua/opencode/commands/handlers/permission.lua create mode 100644 lua/opencode/commands/handlers/session.lua create mode 100644 lua/opencode/commands/handlers/window.lua create mode 100644 lua/opencode/commands/handlers/workflow.lua create mode 100644 lua/opencode/commands/init.lua create mode 100644 lua/opencode/commands/parse.lua create mode 100644 lua/opencode/commands/router.lua create mode 100644 lua/opencode/commands/slash.lua create mode 100644 lua/opencode/registry/builtin_commands.lua create mode 100644 lua/opencode/registry/extensions/commands.lua create mode 100644 lua/opencode/registry/extensions/slash.lua create mode 100644 lua/opencode/registry/init.lua create mode 100644 lua/opencode/registry/loader.lua diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 08cc95b0..661d4fb3 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -1,1617 +1,111 @@ -local core = require('opencode.core') -local util = require('opencode.util') -local session = require('opencode.session') -local config_file = require('opencode.config_file') -local state = require('opencode.state') -local quick_chat = require('opencode.quick_chat') - -local input_window = require('opencode.ui.input_window') -local ui = require('opencode.ui.ui') -local icons = require('opencode.ui.icons') -local git_review = require('opencode.git_review') -local history = require('opencode.history') -local config = require('opencode.config') -local Promise = require('opencode.promise') - -local M = {} - -function M.swap_position() - require('opencode.ui.ui').swap_position() -end - -function M.toggle_zoom() - require('opencode.ui.ui').toggle_zoom() -end - -function M.toggle_input() - input_window.toggle() -end - -function M.open_input() - return core.open({ new_session = false, focus = 'input', start_insert = true }) -end - -function M.open_input_new_session() - return core.open({ new_session = true, focus = 'input', start_insert = true }) -end - -function M.open_output() - return core.open({ new_session = false, focus = 'output' }) -end - -function M.close() - if state.display_route then - state.ui.clear_display_route() - ui.clear_output() - -- need to trigger a re-render here to re-display the session - ui.render_output() - return - end - - ui.teardown_visible_windows(state.windows) -end - -function M.hide() - ui.hide_visible_windows(state.windows) -end - -function M.paste_image() - core.paste_image_from_clipboard() -end - ----@return {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} -function M.get_window_state() - return state.ui.get_window_state() -end - ----@param hidden OpencodeHiddenBuffers|nil ----@return 'input'|'output' -local function resolve_hidden_focus(hidden) - if hidden and (hidden.focused_window == 'input' or hidden.focused_window == 'output') then - return hidden.focused_window - end - - if hidden and hidden.input_hidden then - return 'output' - end - - return 'input' -end - ----@param restore_hidden boolean ----@return {focus: 'input'|'output', open_action: 'reuse_visible'|'restore_hidden'|'create_fresh'} -local function build_toggle_open_context(restore_hidden) - if restore_hidden then - local hidden = state.ui.inspect_hidden_buffers() - return { - focus = resolve_hidden_focus(hidden), - open_action = 'restore_hidden', - } - end - - local focus = config.ui.input.auto_hide and 'input' or state.last_focused_opencode_window or 'input' - - return { - focus = focus, - open_action = 'create_fresh', - } -end - -M.toggle = Promise.async(function(new_session) - local decision = state.ui.resolve_toggle_decision(config.ui.persist_state, state.display_route ~= nil) - local action = decision.action - local is_new_session = new_session == true - - local function open_windows(restore_hidden) - local ctx = build_toggle_open_context(restore_hidden == true) - return core - .open({ - new_session = is_new_session, - focus = ctx.focus, - start_insert = false, - open_action = ctx.open_action, - }) - :await() - end - - local function open_fresh_windows() - return open_windows(false) - end - - local function restore_hidden_windows() - return open_windows(true) - end - - local function migrate_windows() - if state.windows then - ui.teardown_visible_windows(state.windows) - end - return open_fresh_windows() - end - - local action_handlers = { - close = M.close, - hide = M.hide, - close_hidden = ui.drop_hidden_snapshot, - migrate = migrate_windows, - restore_hidden = restore_hidden_windows, - open = open_fresh_windows, - } - - local handler = action_handlers[action] or action_handlers.open - return handler() -end) - ----@param new_session boolean? ----@return nil -function M.toggle_focus(new_session) - if not ui.is_opencode_focused() then - local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output' - core.open({ new_session = new_session == true, focus = focus }) - else - ui.return_to_last_code_win() - end -end - -function M.configure_provider() - core.configure_provider() -end - -function M.configure_variant() - core.configure_variant() -end - -function M.cycle_variant() - core.cycle_variant() -end - -function M.cancel() - core.cancel() -end - ----@param prompt string ----@param opts? SendMessageOpts -function M.run(prompt, opts) - opts = vim.tbl_deep_extend('force', { new_session = false, focus = 'output' }, opts or {}) - return core.open(opts):and_then(function() - return core.send_message(prompt, opts) - end) -end - ----@param prompt string ----@param opts? SendMessageOpts -function M.run_new_session(prompt, opts) - opts = vim.tbl_deep_extend('force', { new_session = true, focus = 'output' }, opts or {}) - return core.open(opts):and_then(function() - return core.send_message(prompt, opts) - end) -end - ----@param parent_id? string -function M.select_session(parent_id) - core.select_session(parent_id) -end - -function M.select_child_session() - core.select_session(state.active_session and state.active_session.id or nil) -end - -function M.select_history() - require('opencode.ui.history_picker').pick() -end - -function M.quick_chat(message, range) - if not range then - if vim.fn.mode():match('[vV\022]') then - local visual_range = util.get_visual_range() - if visual_range then - range = { - start = visual_range.start_line, - stop = visual_range.end_line, - } - end - end - end - - if type(message) == 'table' then - message = table.concat(message, ' ') - end - - if not message or #message == 0 then - local scope = range and ('[selection: ' .. range.start .. '-' .. range.stop .. ']') - or '[line: ' .. tostring(vim.api.nvim_win_get_cursor(0)[1]) .. ']' - vim.ui.input({ prompt = 'Quick Chat Message: ' .. scope, win = { relative = 'cursor' } }, function(input) - if input and input ~= '' then - local prompt, ctx = util.parse_quick_context_args(input) - quick_chat.quick_chat(prompt, { context_config = ctx }, range) - end - end) - return - end - - local prompt, ctx = util.parse_quick_context_args(message) - quick_chat.quick_chat(prompt, { context_config = ctx }, range) -end - -function M.toggle_pane() - ui.toggle_pane() -end - ----@param from_snapshot_id? string ----@param to_snapshot_id? string|number -function M.diff_open(from_snapshot_id, to_snapshot_id) - core.open_if_closed({ new_session = false, focus = 'output' }):and_then(function() - git_review.review(from_snapshot_id) - end) -end - -function M.diff_next() - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.next_diff() - end) -end - -function M.diff_prev() - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.prev_diff() - end) -end - -function M.diff_close() - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.close_diff() - end) -end - ----@param from_snapshot_id? string -function M.diff_revert_all(from_snapshot_id) - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.revert_all(from_snapshot_id) - end) -end - ----@param from_snapshot_id? string ----@param to_snapshot_id? string -function M.diff_revert_selected_file(from_snapshot_id, to_snapshot_id) - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.revert_selected_file(from_snapshot_id) - end) -end - ----@param restore_point_id? string -function M.diff_restore_snapshot_file(restore_point_id) - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.restore_snapshot_file(restore_point_id) - end) -end - ----@param restore_point_id? string -function M.diff_restore_snapshot_all(restore_point_id) - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.restore_snapshot_all(restore_point_id) - end) -end - -function M.diff_revert_all_last_prompt() - core.open({ new_session = false, focus = 'output' }):and_then(function() - local snapshots = session.get_message_snapshot_ids(state.current_message) - local snapshot_id = snapshots and snapshots[1] - if not snapshot_id then - vim.notify('No snapshots found for the current message', vim.log.levels.WARN) - return - end - git_review.revert_all(snapshot_id) - end) -end - -function M.diff_revert_this(snapshot_id) - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.revert_current(snapshot_id) - end) -end - -function M.diff_revert_this_last_prompt() - core.open({ new_session = false, focus = 'output' }):and_then(function() - local snapshots = session.get_message_snapshot_ids(state.current_message) - local snapshot_id = snapshots and snapshots[1] - if not snapshot_id then - vim.notify('No snapshots found for the current message', vim.log.levels.WARN) - return - end - git_review.revert_current(snapshot_id) - end) -end - -function M.set_review_breakpoint() - core.open({ new_session = false, focus = 'output' }):and_then(function() - git_review.create_snapshot() - end) -end - -function M.prev_history() - if not state.ui.is_visible() then - return - end - local prev_prompt = history.prev() - if prev_prompt then - input_window.set_content(prev_prompt) - require('opencode.ui.mention').restore_mentions(state.windows.input_buf) - end -end - -function M.next_history() - if not state.ui.is_visible() then - return - end - local next_prompt = history.next() - if next_prompt then - input_window.set_content(next_prompt) - require('opencode.ui.mention').restore_mentions(state.windows.input_buf) - end -end - -function M.prev_prompt_history() - local key = config.get_key_for_function('input_window', 'prev_prompt_history') - if key ~= '' then - return M.prev_history() - end - local current_line = vim.api.nvim_win_get_cursor(0)[1] - local at_boundary = current_line <= 1 - - if at_boundary then - return M.prev_history() - end - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(key, true, false, true), 'n', false) -end - -function M.next_prompt_history() - local key = config.get_key_for_function('input_window', 'next_prompt_history') - if key ~= '' then - return M.next_history() - end - local current_line = vim.api.nvim_win_get_cursor(0)[1] - local at_boundary = current_line >= vim.api.nvim_buf_line_count(0) - - if at_boundary then - return M.next_history() - end - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(key, true, false, true), 'n', false) -end - -function M.next_message() - require('opencode.ui.navigation').goto_next_message() -end - -function M.prev_message() - require('opencode.ui.navigation').goto_prev_message() -end - -M.submit_input_prompt = Promise.async(function() - if state.display_route then - -- we're displaying /help or something similar, need to clear that and refresh - -- the session data before sending the command - state.ui.clear_display_route() - ui.render_output(true) - end - - local message_sent = input_window.handle_submit() - - -- Only hide input window if a message was actually sent (not slash commands, shell commands, etc.) - if message_sent and config.ui.input.auto_hide and not input_window.is_hidden() then - input_window._hide() - end -end) - -function M.mention_file() - local picker = require('opencode.ui.file_picker') - local context = require('opencode.context') - require('opencode.ui.mention').mention(function(mention_cb) - picker.pick(function(file) - mention_cb(file.path) - context.add_file(file.path) - end) - end) -end - -function M.mention() - local char = config.get_key_for_function('input_window', 'mention') - - ui.focus_input({ restore_position = false, start_insert = true }) - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(char, true, false, true), 'n', false) -end - -function M.context_items() - local char = config.get_key_for_function('input_window', 'context_items') - ui.focus_input({ restore_position = false, start_insert = true }) - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(char, true, false, true), 'n', false) -end - -function M.slash_commands() - local char = config.get_key_for_function('input_window', 'slash_commands') - ui.focus_input({ restore_position = false, start_insert = true }) - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(char, true, false, true), 'n', false) -end - -function M.focus_input() - ui.focus_input({ restore_position = true, start_insert = true }) -end - -function M.references() - require('opencode.ui.reference_picker').pick() -end - -function M.debug_output() - if not config.debug.enabled then - vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) - return - end - local debug_helper = require('opencode.ui.debug_helper') - debug_helper.debug_output() -end - -function M.debug_message() - if not config.debug.enabled then - vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) - return - end - local debug_helper = require('opencode.ui.debug_helper') - debug_helper.debug_message() -end - -function M.debug_session() - if not config.debug.enabled then - vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) - return - end - local debug_helper = require('opencode.ui.debug_helper') - debug_helper.debug_session() -end - ----@type fun(): Promise -M.initialize = Promise.async(function() - local id = require('opencode.id') - - local new_session = core.create_new_session('AGENTS.md Initialization'):await() - if not new_session then - vim.notify('Failed to create new session', vim.log.levels.ERROR) - return - end - if not core.initialize_current_model():await() or not state.current_model then - vim.notify('No model selected', vim.log.levels.ERROR) - return - end - local providerId, modelId = state.current_model:match('^(.-)/(.+)$') - if not providerId or not modelId then - vim.notify('Invalid model format: ' .. tostring(state.current_model), vim.log.levels.ERROR) - return - end - state.session.set_active(new_session) - M.open_input() - state.api_client:init_session(state.active_session.id, { - providerID = providerId, - modelID = modelId, - messageID = id.ascending('message'), - }) -end) - -function M.agent_plan() - require('opencode.core').switch_to_mode('plan') -end - -function M.agent_build() - require('opencode.core').switch_to_mode('build') -end - -M.select_agent = Promise.async(function() - local modes = config_file.get_opencode_agents():await() - local picker = require('opencode.ui.picker') - picker.select(modes, { - prompt = 'Select mode:', - }, function(selection) - if not selection then - return - end - - require('opencode.core').switch_to_mode(selection) - end) -end) - -M.switch_mode = Promise.async(function() - local modes = config_file.get_opencode_agents():await() --[[@as string[] ]] - - local current_index = util.index_of(modes, state.current_mode) - - if current_index == -1 then - current_index = 0 - end - - -- Calculate next index, wrapping around if necessary - local next_index = (current_index % #modes) + 1 - - require('opencode.core').switch_to_mode(modes[next_index]) -end) - -function M.with_header(lines, show_welcome) - show_welcome = show_welcome or false - state.ui.set_display_route('/header') - - local msg = { - '## Opencode.nvim', - '', - ' █▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀', - ' █░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀', - ' ▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀', - '', - } - if show_welcome then - table.insert( - msg, - 'Welcome to Opencode.nvim! This plugin allows you to interact with AI models directly from Neovim.' - ) - table.insert(msg, '') - end - - for _, line in ipairs(lines) do - table.insert(msg, line) - end - return msg -end - -function M.help() - state.ui.set_display_route('/help') - M.open_input() - local msg = M.with_header({ - '### Available Commands', - '', - 'Use `:Opencode ` to run commands. Examples:', - '', - '- `:Opencode open input` - Open the input window', - '- `:Opencode session new` - Create a new session', - '- `:Opencode diff open` - Open diff view', - '', - '### Subcommands', - '', - '| Command | Description |', - '|--------------|-------------|', - }, false) - - if not state.ui.is_visible() or not state.windows.output_win then - return - end - - local max_desc_length = math.min(90, vim.api.nvim_win_get_width(state.windows.output_win) - 35) - - local sorted_commands = vim.tbl_keys(M.commands) - table.sort(sorted_commands) - - for _, name in ipairs(sorted_commands) do - local def = M.commands[name] - local desc = def.desc or '' - if #desc > max_desc_length then - desc = desc:sub(1, max_desc_length - 3) .. '...' - end - table.insert(msg, string.format('| %-12s | %-' .. max_desc_length .. 's |', name, desc)) - end - - table.insert(msg, '') - table.insert(msg, 'For slash commands (e.g., /models, /help), type `/` in the input window.') - table.insert(msg, '') - ui.render_lines(msg) -end - -M.mcp = Promise.async(function() - local mcp_picker = require('opencode.ui.mcp_picker') - mcp_picker.pick() -end) - -M.commands_list = Promise.async(function() - local commands = config_file.get_user_commands():await() - if not commands then - vim.notify('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) - return - end - - state.ui.set_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) - -M.current_model = Promise.async(function() - return core.initialize_current_model() -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. -M.run_user_command = Promise.async(function(name, args) - return M.open_input():and_then(function() - local user_commands = config_file.get_user_commands():await() - local command_cfg = user_commands and user_commands[name] - if not command_cfg then - vim.notify('Unknown user command: ' .. name, vim.log.levels.WARN) - return - end - - local model = command_cfg.model or state.current_model - local agent = command_cfg.agent or state.current_mode - - if not state.active_session then - vim.notify('No active session', vim.log.levels.WARN) - return - end - state.api_client - :send_command(state.active_session.id, { - command = name, - arguments = table.concat(args or {}, ' '), - model = model, - agent = agent, - }) - :and_then(function() - vim.schedule(function() - require('opencode.history').write('/' .. name .. ' ' .. table.concat(args or {}, ' ')) - end) - end) - end) --[[@as Promise ]] -end) - ---- Compacts the current session by removing unnecessary data. ---- @param current_session? Session The session to compact. Defaults to the active session. -function M.compact_session(current_session) - current_session = current_session or state.active_session - if not current_session then - vim.notify('No active session to compact', vim.log.levels.WARN) - return - end - - local current_model = state.current_model - if not current_model then - vim.notify('No model selected', vim.log.levels.ERROR) - return - end - local providerId, modelId = current_model:match('^(.-)/(.+)$') - if not providerId or not modelId then - vim.notify('Invalid model format: ' .. tostring(current_model), vim.log.levels.ERROR) - return - end - state.api_client - :summarize_session(current_session.id, { - providerID = providerId, - modelID = modelId, - }) - :and_then(function() - vim.schedule(function() - vim.notify('Session compacted successfully', vim.log.levels.INFO) - end) - end) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to compact session: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) -end - -function M.share() - if not state.active_session then - vim.notify('No active session to share', vim.log.levels.WARN) - return - end - - state.api_client - :share_session(state.active_session.id) - :and_then(function(response) - vim.schedule(function() - if response and response.share and response.share.url then - vim.fn.setreg('+', response.share.url) - vim.notify('Session link copied to clipboard successfully: ' .. response.share.url, vim.log.levels.INFO) - else - vim.notify('Session shared but no link received', vim.log.levels.WARN) - end - end) - end) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to share session: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) -end - -function M.unshare() - if not state.active_session then - vim.notify('No active session to unshare', vim.log.levels.WARN) - return - end - - state.api_client - :unshare_session(state.active_session.id) - :and_then(function(response) - vim.schedule(function() - vim.notify('Session unshared successfully', vim.log.levels.INFO) - end) - end) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to unshare session: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) -end - ----@param messageId? string -function M.undo(messageId) - if not state.active_session then - vim.notify('No active session to undo', vim.log.levels.WARN) - return - end - - local message_to_revert = messageId or (state.last_user_message and state.last_user_message.info.id) - if not message_to_revert then - vim.notify('No user message to undo', vim.log.levels.WARN) - return - end - - state.api_client - :revert_message(state.active_session.id, { - messageID = message_to_revert, - }) - :and_then(function(response) - vim.schedule(function() - vim.cmd('checktime') - end) - end) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to undo last message: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) -end - -function M.timeline() - local user_messages = {} - for _, msg in ipairs(state.messages or {}) do - local parts = msg.parts or {} - local is_summary = #parts == 1 and parts[1].synthetic == true - if msg.info.role == 'user' and not is_summary then - table.insert(user_messages, msg) - end - end - if #user_messages == 0 then - vim.notify('No user messages in the current session', vim.log.levels.WARN) - return - end - - local timeline_picker = require('opencode.ui.timeline_picker') - timeline_picker.pick(user_messages, function(selected_msg) - if selected_msg then - require('opencode.ui.navigation').goto_message_by_id(selected_msg.info.id) - end - end) -end - ---- Forks the current session from a specific user message. ----@param message_id? string The ID of the user message to fork from. If not provided, uses the last user message. -function M.fork_session(message_id) - if not state.active_session then - vim.notify('No active session to fork', vim.log.levels.WARN) - return - end - - local message_to_fork = message_id or state.last_user_message and state.last_user_message.info.id - if not message_to_fork then - vim.notify('No user message to fork from', vim.log.levels.WARN) - return - end - - state.api_client - :fork_session(state.active_session.id, { - messageID = message_to_fork, - }) - :and_then(function(response) - vim.schedule(function() - if response and response.id then - vim.notify('Session forked successfully. New session ID: ' .. response.id, vim.log.levels.INFO) - core.switch_session(response.id) - else - vim.notify('Session forked but no new session ID received', vim.log.levels.WARN) - end - end) - end) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to fork session: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) -end - ----@param current_session? Session ---- @param new_title? string -M.rename_session = Promise.async(function(current_session, new_title) - local promise = require('opencode.promise').new() - current_session = current_session or (state.active_session and vim.deepcopy(state.active_session) or nil) --[[@as Session]] - if not current_session then - vim.notify('No active session to rename', vim.log.levels.WARN) - promise:resolve(nil) - return promise - end - local function rename_session_with_title(title) - state.api_client - :update_session(current_session.id, { title = title }) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to rename session: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) - :and_then(Promise.async(function() - current_session.title = title - if state.active_session and state.active_session.id == current_session.id then - local session_obj = session.get_by_id(current_session.id):await() - if session_obj then - session_obj.title = title - state.session.set_active(vim.deepcopy(session_obj)) - end - end - promise:resolve(current_session) - end)) - end - - if new_title and new_title ~= '' then - rename_session_with_title(new_title) - return promise - end - - vim.schedule(function() - vim.ui.input({ prompt = 'New session name: ', default = current_session.title or '' }, function(input) - if input and input ~= '' then - rename_session_with_title(input) - else - promise:resolve(nil) - end - end) - end) - return promise -end) - --- Returns the ID of the next user message after the current undo point --- This is a port of the opencode tui logic --- https://github.com/sst/opencode/blob/dev/packages/tui/internal/components/chat/messages.go#L1199 -local function find_next_message_for_redo() - if not state.active_session then - return nil - end - - local revert_time = 0 - local revert = state.active_session.revert - - if not revert then - return nil - end - - for _, message in ipairs(state.messages or {}) do - if message.info.id == revert.messageID then - revert_time = math.floor(message.info.time.created) - break - end - if revert.partID and revert.partID ~= '' then - for _, part in ipairs(message.parts) do - if part.id == revert.partID and part.state and part.state.time then - revert_time = math.floor(part.state.time.start) - break - end - end - end - end - - -- Find next user message after revert time - local next_message_id = nil - for _, msg in ipairs(state.messages or {}) do - if msg.info.role == 'user' and msg.info.time.created > revert_time then - next_message_id = msg.info.id - break - end - end - return next_message_id -end - -function M.redo() - if not state.active_session then - vim.notify('No active session to redo', vim.log.levels.WARN) - return - end - - if not state.active_session.revert or state.active_session.revert.messageID == '' then - vim.notify('Nothing to redo', vim.log.levels.WARN) - return - end - - if not state.messages then - return - end - - local next_message_id = find_next_message_for_redo() - if not next_message_id then - state.api_client - :unrevert_messages(state.active_session.id) - :and_then(function(response) - vim.schedule(function() - vim.cmd('checktime') - end) - end) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to redo message: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) - else - -- Calling revert on a "later" message is like a redo - state.api_client - :revert_message(state.active_session.id, { - messageID = next_message_id, - }) - :and_then(function(response) - vim.schedule(function() - vim.cmd('checktime') - end) - end) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to redo message: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) - end -end - ----@param answer? 'once'|'always'|'reject' ----@param permission? OpencodePermission -function M.respond_to_permission(answer, permission) - answer = answer or 'once' - - local permission_window = require('opencode.ui.permission_window') - local current_permission = permission or permission_window.get_current_permission() - - if not current_permission then - vim.notify('No permission request to accept', vim.log.levels.WARN) - return - end - - state.api_client - :respond_to_permission(current_permission.sessionID, current_permission.id, { response = answer }) - :catch(function(err) - vim.schedule(function() - vim.notify('Failed to reply to permission: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) -end - ----@param permission? OpencodePermission -function M.permission_accept(permission) - M.respond_to_permission('once', permission) -end - ----@param permission? OpencodePermission -function M.permission_accept_all(permission) - M.respond_to_permission('always', permission) -end - ----@param permission? OpencodePermission -function M.permission_deny(permission) - M.respond_to_permission('reject', permission) -end - -function M.question_answer() - local question_window = require('opencode.ui.question_window') - local question_info = question_window.get_current_question_info() - if question_info and question_info.options and question_info.options[1] then - question_window._answer_with_option(1) - end -end - -function M.question_other() - local question_window = require('opencode.ui.question_window') - if question_window.has_question() then - question_window._answer_with_custom() - end -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 fun(): Promise -M.review = Promise.async(function(args) - local id = require('opencode.id') - - local new_session = core.create_new_session('Code review checklist for diffs and PRs'):await() - if not new_session then - vim.notify('Failed to create new session', vim.log.levels.ERROR) - return - end - if not core.initialize_current_model():await() or not state.current_model then - vim.notify('No model selected', vim.log.levels.ERROR) - return - end - local providerId, modelId = state.current_model:match('^(.-)/(.+)$') - if not providerId or not modelId then - vim.notify('Invalid model format: ' .. tostring(state.current_model), vim.log.levels.ERROR) - return - end - state.session.set_active(new_session) - M.open_input():await() - state.api_client - :send_command(state.active_session.id, { - command = 'review', - arguments = table.concat(args or {}, ' '), - model = state.current_model, - }) - :and_then(function() - vim.schedule(function() - require('opencode.history').write('/review ' .. table.concat(args or {}, ' ')) - end) - end) -end) - -M.add_visual_selection = Promise.async( - ---@param opts? {open_input?: boolean} - ---@param range OpencodeSelectionRange - function(opts, range) - opts = vim.tbl_extend('force', { open_input = true }, opts or {}) - local context = require('opencode.context') - local added = context.add_visual_selection(range) - - if added and opts.open_input then - M.open_input():await() - end - end -) - -M.add_visual_selection_inline = Promise.async( - ---@param opts? {open_input?: boolean} - ---@param range OpencodeSelectionRange - function(opts, range) - opts = vim.tbl_extend('force', { open_input = true }, opts or {}) - local context = require('opencode.context') - local text = context.build_inline_selection_text(range) - - if not text then - return - end - - M.open_input():await() - local input_window = require('opencode.ui.input_window') - input_window._append_to_input(text) - vim.schedule(function() - if vim.fn.mode() ~= 'n' then - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', false) - end - end) - end -) - ----@type table -M.commands = { - open = { - desc = 'Open opencode window (input/output)', - completions = { 'input', 'output' }, - fn = function(args) - local target = args[1] or 'input' - if target == 'input' then - M.open_input() - elseif target == 'output' then - M.open_output() - else - vim.notify('Invalid target. Use: input or output', vim.log.levels.ERROR) - end - end, - }, - - close = { - desc = 'Close opencode windows', - fn = function(args) - M.close() - end, - }, - - hide = { - desc = 'Hide opencode windows (preserve buffers for fast restore)', - fn = function(args) - M.hide() - end, - }, - - cancel = { - desc = 'Cancel running request', - fn = M.cancel, - }, - - toggle = { - desc = 'Toggle opencode windows', - fn = M.toggle, - }, - - toggle_focus = { - desc = 'Toggle focus between opencode and code', - fn = M.toggle_focus, - }, - - toggle_pane = { - desc = 'Toggle between input/output panes', - fn = M.toggle_pane, - }, - - toggle_zoom = { - desc = 'Toggle window zoom', - fn = M.toggle_zoom, - }, - - toggle_input = { - desc = 'Toggle input window visibility', - fn = M.toggle_input, - }, - - quick_chat = { - desc = 'Quick chat with current buffer or visual selection', - fn = M.quick_chat, - range = true, -- Enable range support for visual selections - nargs = '+', -- Allow multiple arguments - complete = false, -- No completion for custom messages - }, - - swap = { - desc = 'Swap pane position left/right', - fn = M.swap_position, - }, - - review = { - desc = 'Review changes (commit/branch/pr), defaults to uncommitted changes', - fn = function(args) - M.review(args) - end, - nargs = '+', - }, - - session = { - desc = 'Manage sessions (new/select/child/compact/share/unshare/rename)', - completions = { 'new', 'select', 'child', 'compact', 'share', 'unshare', 'agents_init', 'rename' }, - fn = function(args) - local subcmd = args[1] - if subcmd == 'new' then - Promise.spawn(function() - local title = table.concat(vim.list_slice(args, 2), ' ') - if title and title ~= '' then - local new_session = core.create_new_session(title):await() - if not new_session then - vim.notify('Failed to create new session', vim.log.levels.ERROR) - return - end - state.session.set_active(new_session) - M.open_input() - else - M.open_input_new_session() - end - end) - elseif subcmd == 'select' then - M.select_session() - elseif subcmd == 'child' then - M.select_child_session() - elseif subcmd == 'compact' then - M.compact_session() - elseif subcmd == 'share' then - M.share() - elseif subcmd == 'unshare' then - M.unshare() - elseif subcmd == 'agents_init' then - M.initialize() - elseif subcmd == 'rename' then - local title = table.concat(vim.list_slice(args, 2), ' ') - M.rename_session(state.active_session, title) - else - local valid_subcmds = table.concat(M.commands.session.completions or {}, ', ') - vim.notify('Invalid session subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) - end - end, - }, - - undo = { - desc = 'Undo last action', - fn = function(args) - local message_id = args[1] - M.undo(message_id) - end, - }, - - redo = { - desc = 'Redo last action', - fn = M.redo, - }, - - diff = { - desc = 'View file diffs (open/next/prev/close)', - completions = { 'open', 'next', 'prev', 'close' }, - fn = function(args) - local subcmd = args[1] - if not subcmd or subcmd == 'open' then - M.diff_open() - elseif subcmd == 'next' then - M.diff_next() - elseif subcmd == 'prev' then - M.diff_prev() - elseif subcmd == 'close' then - M.diff_close() - else - local valid_subcmds = table.concat(M.commands.diff.completions or {}, ', ') - vim.notify('Invalid diff subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) - end - end, - }, - - revert = { - desc = 'Revert changes (all/this, prompt/session)', - completions = { 'all', 'this' }, - sub_completions = { 'prompt', 'session' }, - fn = function(args) - local scope = args[1] - local target = args[2] - - if scope == 'all' then - if target == 'prompt' then - M.diff_revert_all_last_prompt() - elseif target == 'session' then - M.diff_revert_all(nil) - elseif target then - M.diff_revert_all(target) - else - vim.notify('Invalid revert target. Use: prompt, session, or ', vim.log.levels.ERROR) - end - elseif scope == 'this' then - if target == 'prompt' then - M.diff_revert_this_last_prompt() - elseif target == 'session' then - M.diff_revert_this(nil) - elseif target then - M.diff_revert_this(target) - else - vim.notify('Invalid revert target. Use: prompt, session, or ', vim.log.levels.ERROR) - end - else - vim.notify('Invalid revert scope. Use: all or this', vim.log.levels.ERROR) - end - end, - }, - - restore = { - desc = 'Restore from snapshot (file/all)', - completions = { 'file', 'all' }, - fn = function(args) - local scope = args[1] - local snapshot_id = args[2] - - if not snapshot_id then - vim.notify('Snapshot ID required', vim.log.levels.ERROR) - return - end - - if scope == 'file' then - M.diff_restore_snapshot_file(snapshot_id) - elseif scope == 'all' then - M.diff_restore_snapshot_all(snapshot_id) - else - vim.notify('Invalid restore scope. Use: file or all', vim.log.levels.ERROR) - end - end, - }, - - breakpoint = { - desc = 'Set review breakpoint', - fn = M.set_review_breakpoint, - }, - - agent = { - desc = 'Manage agents (plan/build/select)', - completions = { 'plan', 'build', 'select' }, - fn = function(args) - local subcmd = args[1] - if subcmd == 'plan' then - M.agent_plan() - elseif subcmd == 'build' then - M.agent_build() - elseif subcmd == 'select' then - M.select_agent() - else - local valid_subcmds = table.concat(M.commands.agent.completions or {}, ', ') - vim.notify('Invalid agent subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) - end - end, - }, - - models = { - desc = 'Switch provider/model', - fn = M.configure_provider, - }, - - variant = { - desc = 'Switch model variant', - fn = M.configure_variant, - }, - - run = { - desc = 'Run prompt in current session', - fn = function(args) - local opts, prompt = util.parse_run_args(args) - if prompt == '' then - vim.notify('Prompt required', vim.log.levels.ERROR) - return - end - return M.run(prompt, opts) - end, - }, - - run_new = { - desc = 'Run prompt in new session', - fn = function(args) - local opts, prompt = util.parse_run_args(args) - if prompt == '' then - vim.notify('Prompt required', vim.log.levels.ERROR) - return - end - return M.run_new_session(prompt, opts) - end, - }, - - command = { - desc = 'Run user-defined command', - completions = function() - local user_commands = config_file.get_user_commands():wait() - 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 - vim.notify('Command name required', vim.log.levels.ERROR) - return - end - M.run_user_command(name, vim.list_slice(args, 2)) - end, - }, - - help = { - desc = 'Show this help message', - fn = M.help, - }, - - history = { - desc = 'Select from prompt history', - fn = M.select_history, - }, - - mcp = { - desc = 'Show MCP server configuration', - 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' }, - fn = function(args) - local subcmd = args[1] - local index = tonumber(args[2]) - local permission = nil - if index then - local permission_window = require('opencode.ui.permission_window') - local permissions = permission_window.get_all_permissions() - if not permissions or not permissions[index] then - vim.notify('Invalid permission index: ' .. tostring(index), vim.log.levels.ERROR) - return - end - permission = permissions[index] - end - - if subcmd == 'accept' then - M.permission_accept(permission) - elseif subcmd == 'accept_all' then - M.permission_accept_all(permission) - elseif subcmd == 'deny' then - M.permission_deny(permission) - else - local valid_subcmds = table.concat(M.commands.permission.completions or {}, ', ') - vim.notify('Invalid permission subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) - end - end, - }, - - timeline = { - desc = 'Open timeline picker to navigate/undo/redo/fork to message', - fn = M.timeline, - }, - - toggle_tool_output = { - 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, - }, - references = { - desc = 'Browse code references from conversation', - fn = M.references, - }, - - add_visual_selection = { - desc = 'Add current visual selection to context', - fn = M.add_visual_selection, - }, - - add_visual_selection_inline = { - desc = 'Insert visual selection as inline code block in the input buffer', - fn = M.add_visual_selection_inline, - }, +local window = require('opencode.commands.handlers.window').actions +local session = require('opencode.commands.handlers.session').actions +local diff = require('opencode.commands.handlers.diff').actions +local workflow = require('opencode.commands.handlers.workflow').actions +local permission = require('opencode.commands.handlers.permission').actions +local agent = require('opencode.commands.handlers.agent').actions + +local window_api = { + swap_position = window.swap_position, + toggle_zoom = window.toggle_zoom, + toggle_input = window.toggle_input, + open_input = window.open_input, + open_output = window.open_output, + close = window.close, + hide = window.hide, + get_window_state = window.get_window_state, + toggle_focus = window.toggle_focus, + toggle_pane = window.toggle_pane, + focus_input = window.focus_input, + cancel = window.cancel, + toggle = window.toggle, } -M.slash_commands_map = { - ['/help'] = { fn = M.help, desc = 'Show help message' }, - ['/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' }, - ['/history'] = { fn = M.select_history, desc = 'Select from history' }, - ['/mcp'] = { fn = M.mcp, desc = 'Show MCP server configuration' }, - ['/models'] = { fn = M.configure_provider, desc = 'Switch provider/model' }, - ['/variant'] = { fn = M.configure_variant, desc = 'Switch model variant' }, - ['/new'] = { fn = M.open_input_new_session, desc = 'Create new session' }, - ['/redo'] = { fn = M.redo, desc = 'Redo last action' }, - ['/sessions'] = { fn = M.select_session, desc = 'Select session' }, - ['/share'] = { fn = M.share, desc = 'Share current session' }, - ['/timeline'] = { fn = M.timeline, desc = 'Open timeline picker' }, - ['/references'] = { fn = M.references, desc = 'Browse code references from conversation' }, - ['/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' }, - ['/review'] = { - fn = M.review, - desc = 'Review changes [commit|branch|pr], defaults to uncommitted changes', - args = true, - }, +local session_api = { + open_input_new_session = session.open_input_new_session, + select_session = session.select_session, + select_child_session = session.select_child_session, + compact_session = session.compact_session, + share = session.share, + unshare = session.unshare, + initialize = session.initialize, + open_input_new_session_with_title = session.open_input_new_session_with_title, + rename_session = session.rename_session, } -function M.route_command(opts) - local args = vim.split(opts.args or '', '%s+', { trimempty = true }) - ---@type OpencodeSelectionRange|nil - local range = nil - - if opts.range and opts.range > 0 then - range = { - start = opts.line1, - stop = opts.line2, - } - end - - if #args == 0 then - M.toggle() - return - end - - local subcommand = args[1] - local subcmd_def = M.commands[subcommand] - - if subcmd_def and subcmd_def.fn then - subcmd_def.fn(vim.list_slice(args, 2), range) - else - vim.notify('Unknown subcommand: ' .. subcommand, vim.log.levels.ERROR) - end -end - -function M.complete_command(arg_lead, cmd_line, cursor_pos) - local parts = vim.split(cmd_line, '%s+', { trimempty = false }) - local num_parts = #parts - - if num_parts <= 2 then - local subcommands = vim.tbl_keys(M.commands) - table.sort(subcommands) - return vim.tbl_filter(function(cmd) - return vim.startswith(cmd, arg_lead) - end, subcommands) - end - - local subcommand = parts[2] - local subcmd_def = M.commands[subcommand] - - if not subcmd_def then - return {} - 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, completions) - end - - if num_parts <= 4 and subcmd_def.sub_completions then - return vim.tbl_filter(function(opt) - return vim.startswith(opt, arg_lead) - end, subcmd_def.sub_completions) - end - - return {} -end +local diff_api = { + diff_open = diff.diff_open, + diff_next = diff.diff_next, + diff_prev = diff.diff_prev, + diff_close = diff.diff_close, + diff_revert_all = diff.diff_revert_all, + diff_revert_selected_file = diff.diff_revert_selected_file, + diff_restore_snapshot_file = diff.diff_restore_snapshot_file, + diff_restore_snapshot_all = diff.diff_restore_snapshot_all, + diff_revert_all_last_prompt = diff.diff_revert_all_last_prompt, + diff_revert_this = diff.diff_revert_this, + diff_revert_this_last_prompt = diff.diff_revert_this_last_prompt, + set_review_breakpoint = diff.set_review_breakpoint, +} -M.get_slash_commands = Promise.async(function() - local result = {} - for slash_cmd, def in pairs(M.slash_commands_map) do - table.insert(result, { - slash_cmd = slash_cmd, - desc = def.desc, - fn = def.fn, - args = def.args or false, - }) - end +local workflow_api = { + paste_image = workflow.paste_image, + run = workflow.run, + run_new_session = workflow.run_new_session, + select_history = workflow.select_history, + quick_chat = workflow.quick_chat, + prev_history = workflow.prev_history, + next_history = workflow.next_history, + prev_prompt_history = workflow.prev_prompt_history, + next_prompt_history = workflow.next_prompt_history, + next_message = workflow.next_message, + prev_message = workflow.prev_message, + mention_file = workflow.mention_file, + mention = workflow.mention, + context_items = workflow.context_items, + slash_commands = workflow.slash_commands, + references = workflow.references, + debug_output = workflow.debug_output, + debug_message = workflow.debug_message, + debug_session = workflow.debug_session, + with_header = workflow.with_header, + help = workflow.help, + undo = workflow.undo, + timeline = workflow.timeline, + fork_session = workflow.fork_session, + redo = workflow.redo, + toggle_tool_output = workflow.toggle_tool_output, + toggle_reasoning_output = workflow.toggle_reasoning_output, + submit_input_prompt = workflow.submit_input_prompt, + mcp = workflow.mcp, + commands_list = workflow.commands_list, + run_user_command = workflow.run_user_command, + review = workflow.review, + add_visual_selection = workflow.add_visual_selection, + add_visual_selection_inline = workflow.add_visual_selection_inline, +} - local user_commands = config_file.get_user_commands():await() - 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(...) - return M.run_user_command(name, ...) - end, - args = true, - }) - end - end +local permission_api = { + respond_to_permission = permission.respond_to_permission, + permission_accept = permission.permission_accept, + permission_accept_all = permission.permission_accept_all, + permission_deny = permission.permission_deny, + question_answer = permission.question_answer, + question_other = permission.question_other, +} - return result -end) +local agent_api = { + configure_provider = agent.configure_provider, + configure_variant = agent.configure_variant, + cycle_variant = agent.cycle_variant, + agent_plan = agent.agent_plan, + agent_build = agent.agent_build, + select_agent = agent.select_agent, + switch_mode = agent.switch_mode, + current_model = agent.current_model, +} -function M.setup() - vim.api.nvim_create_user_command('Opencode', M.route_command, { - desc = 'Opencode.nvim main command with nested subcommands', - nargs = '*', - range = true, -- Enable range support - complete = M.complete_command, - }) -end +---@type OpencodeCommandApi +local M = vim.tbl_extend('force', {}, window_api, session_api, diff_api, workflow_api, permission_api, agent_api) return M diff --git a/lua/opencode/commands/completion_providers.lua b/lua/opencode/commands/completion_providers.lua new file mode 100644 index 00000000..4b3d3e8f --- /dev/null +++ b/lua/opencode/commands/completion_providers.lua @@ -0,0 +1,24 @@ +local M = {} + +---@type table +local providers = { + user_commands = function() + local config_file = require('opencode.config_file') + local user_commands = config_file.get_user_commands():wait() + if not user_commands then + return {} + end + + local names = vim.tbl_keys(user_commands) + table.sort(names) + return names + end, +} + +---@param provider_id string +---@return (fun(): string[])|nil +function M.get(provider_id) + return providers[provider_id] +end + +return M diff --git a/lua/opencode/commands/dispatch.lua b/lua/opencode/commands/dispatch.lua new file mode 100644 index 00000000..c96106d5 --- /dev/null +++ b/lua/opencode/commands/dispatch.lua @@ -0,0 +1,194 @@ +local handlers = require('opencode.commands.handlers') +local config = require('opencode.config') +local registry = require('opencode.registry') +local state = require('opencode.state') + +local M = {} + +local lifecycle_hook_keys = { + before = 'on_command_before', + after = 'on_command_after', + error = 'on_command_error', + finally = 'on_command_finally', +} + +local lifecycle_event_names = { + before = 'custom.command.before', + after = 'custom.command.after', + error = 'custom.command.error', + finally = 'custom.command.finally', +} + +---@param event_name string +---@param payload table +local function emit_lifecycle_event(event_name, payload) + local manager = state.event_manager + if manager and type(manager.emit) == 'function' then + pcall(manager.emit, manager, event_name, payload) + end +end + +---@param stage OpencodeCommandLifecycleStage +---@param hook_id string +---@param hook_fn OpencodeCommandDispatchHook +---@param ctx OpencodeCommandDispatchContext +---@return OpencodeCommandDispatchContext +local function run_hook(stage, hook_id, hook_fn, ctx) + local ok, next_ctx_or_err = pcall(hook_fn, ctx) + if not ok then + emit_lifecycle_event('custom.command.hook_error', { + stage = stage, + hook_id = hook_id, + error = tostring(next_ctx_or_err), + context = ctx, + }) + return ctx + end + + if type(next_ctx_or_err) == 'table' then + return next_ctx_or_err + end + + return ctx +end + +---@param stage OpencodeCommandLifecycleStage +---@return { id: string, fn: OpencodeCommandDispatchHook }[] +local function collect_registry_stage_hooks(stage) + local hooks = registry.get_hooks() + local hook_names = vim.tbl_keys(hooks) + table.sort(hook_names) + + local stage_hooks = {} + for _, hook_name in ipairs(hook_names) do + local hook_spec = hooks[hook_name] + local hook_fn + + if type(hook_spec) == 'table' then + hook_fn = hook_spec[stage] + if type(hook_fn) ~= 'function' then + hook_fn = hook_spec[lifecycle_hook_keys[stage]] + end + elseif type(hook_spec) == 'function' and (hook_name == stage or hook_name == lifecycle_hook_keys[stage]) then + hook_fn = hook_spec + end + + if type(hook_fn) == 'function' then + stage_hooks[#stage_hooks + 1] = { + id = 'registry:' .. hook_name, + fn = hook_fn, + } + end + end + + return stage_hooks +end + +---@param stage OpencodeCommandLifecycleStage +---@param ctx OpencodeCommandDispatchContext +---@return OpencodeCommandDispatchContext +local function run_hook_pipeline(stage, ctx) + local next_ctx = ctx + + for _, hook in ipairs(collect_registry_stage_hooks(stage)) do + next_ctx = run_hook(stage, hook.id, hook.fn, next_ctx) + end + + local hooks = config.hooks + if hooks then + local config_hook_name = lifecycle_hook_keys[stage] + local config_hook = hooks[config_hook_name] + if type(config_hook) == 'function' then + next_ctx = run_hook(stage, 'config:' .. config_hook_name, config_hook, next_ctx) + end + end + + emit_lifecycle_event(lifecycle_event_names[stage], next_ctx) + + return next_ctx +end + +---@param parsed OpencodeCommandParseResult +---@param api OpencodeCommandApi +---@return OpencodeCommandDispatchResult +function M.command(parsed, api) + ---@type OpencodeCommandDispatchContext + local ctx = { + parsed = parsed, + intent = parsed.intent, + args = parsed.intent and parsed.intent.args or nil, + range = parsed.intent and parsed.intent.range or nil, + } + + if not parsed.ok then + ctx.error = parsed.error + ctx = run_hook_pipeline('error', ctx) + ctx = run_hook_pipeline('finally', ctx) + + return { + ok = false, + error = ctx.error, + } + end + + ctx = run_hook_pipeline('before', ctx) + + local intent = ctx.intent or parsed.intent + local args = ctx.args + if args == nil and intent then + args = intent.args + end + + local range = ctx.range + if range == nil and intent then + range = intent.range + end + + if intent then + intent.args = args or {} + intent.range = range + ctx.intent = intent + end + + local ok, result, handler_error = handlers.execute(intent.handler_id, api, intent.args, intent.range) + + if not ok then + ctx.error = { + code = 'unknown_handler', + message = 'Unknown command handler: ' .. intent.handler_id, + handler_id = intent.handler_id, + } + ctx = run_hook_pipeline('error', ctx) + ctx = run_hook_pipeline('finally', ctx) + + return { + ok = false, + intent = ctx.intent, + error = ctx.error, + } + end + + if handler_error then + ctx.error = handler_error + ctx = run_hook_pipeline('error', ctx) + ctx = run_hook_pipeline('finally', ctx) + + return { + ok = false, + intent = ctx.intent, + error = ctx.error, + } + end + + ctx.result = result + ctx = run_hook_pipeline('after', ctx) + ctx = run_hook_pipeline('finally', ctx) + + return { + ok = true, + result = ctx.result, + intent = ctx.intent, + } +end + +return M diff --git a/lua/opencode/commands/handlers.lua b/lua/opencode/commands/handlers.lua new file mode 100644 index 00000000..8a37f6c2 --- /dev/null +++ b/lua/opencode/commands/handlers.lua @@ -0,0 +1,104 @@ +local M = {} +local registry = require('opencode.registry') + +local handler_group_modules = { + 'opencode.commands.handlers.window', + 'opencode.commands.handlers.agent', + 'opencode.commands.handlers.workflow', + 'opencode.commands.handlers.session', + 'opencode.commands.handlers.diff', + 'opencode.commands.handlers.permission', +} + +---@return OpencodeCommandHandlerMap +local function get_handlers() + local all_handlers = {} + local handler_sources = {} + + for _, module_name in ipairs(handler_group_modules) do + local module_exports = require(module_name) + local module_handlers = module_exports.handlers or module_exports + for handler_id, handler in pairs(module_handlers) do + if handler_sources[handler_id] then + error( + string.format( + "Duplicate handler_id '%s' in modules '%s' and '%s'", + handler_id, + handler_sources[handler_id], + module_name + ) + ) + end + + handler_sources[handler_id] = module_name + all_handlers[handler_id] = handler + end + end + + for handler_id, handler in pairs(registry.get_handlers()) do + if handler_sources[handler_id] then + error( + string.format( + "Duplicate handler_id '%s' in modules '%s' and extension registry", + handler_id, + handler_sources[handler_id] + ) + ) + end + + handler_sources[handler_id] = 'extension registry' + all_handlers[handler_id] = handler + end + + return all_handlers +end + +-- Validate handler graph during module load for fast-fail behavior. +get_handlers() + +---@param handler_id string +---@return OpencodeCommandHandler|nil +function M.get(handler_id) + return get_handlers()[handler_id] +end + +---@return string[] +function M.ids() + local ids = vim.tbl_keys(get_handlers()) + table.sort(ids) + return ids +end + +---@param handler_id string +---@param api OpencodeCommandApi +---@param args string[] +---@param range? OpencodeSelectionRange +---@return boolean, any, OpencodeCommandDispatchError|nil +function M.execute(handler_id, api, args, range) + local handler = M.get(handler_id) + if not handler then + return false, nil, nil + end + + local ok, result = pcall(handler, api, args or {}, range) + if ok then + return true, result, nil + end + + if type(result) == 'table' and type(result.code) == 'string' and type(result.message) == 'string' then + if not result.handler_id then + result.handler_id = handler_id + end + return true, nil, result + end + + return true, + nil, + { + code = 'handler_exception', + message = 'Command handler failed: ' .. handler_id, + handler_id = handler_id, + } +end + +return M diff --git a/lua/opencode/commands/handlers/agent.lua b/lua/opencode/commands/handlers/agent.lua new file mode 100644 index 00000000..1450b0ba --- /dev/null +++ b/lua/opencode/commands/handlers/agent.lua @@ -0,0 +1,91 @@ +local core = require('opencode.core') +local config_file = require('opencode.config_file') +local state = require('opencode.state') +local util = require('opencode.util') +local Promise = require('opencode.promise') + +local M = { + actions = {}, + handlers = {}, +} + +---@param message string +local function invalid_arguments(message) + error({ + code = 'invalid_arguments', + message = message, + }, 0) +end + +function M.actions.configure_provider() + core.configure_provider() +end + +function M.actions.configure_variant() + core.configure_variant() +end + +function M.actions.cycle_variant() + core.cycle_variant() +end + +function M.actions.agent_plan() + core.switch_to_mode('plan') +end + +function M.actions.agent_build() + core.switch_to_mode('build') +end + +M.actions.select_agent = Promise.async(function() + local modes = config_file.get_opencode_agents():await() + local picker = require('opencode.ui.picker') + picker.select(modes, { + prompt = 'Select mode:', + }, function(selection) + if not selection then + return + end + + core.switch_to_mode(selection) + end) +end) + +M.actions.switch_mode = Promise.async(function() + local modes = config_file.get_opencode_agents():await() --[[@as string[] ]] + local current_index = util.index_of(modes, state.store.get('current_mode')) + + if current_index == -1 then + current_index = 0 + end + + local next_index = (current_index % #modes) + 1 + core.switch_to_mode(modes[next_index]) +end) + +M.actions.current_model = Promise.async(function() + return core.initialize_current_model() +end) + +M.handlers.models = M.actions.configure_provider +M.handlers.variant = M.actions.configure_variant + +---@type table +local agent_subcommand_calls = { + plan = M.actions.agent_plan, + build = M.actions.agent_build, + select = M.actions.select_agent, +} + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.agent(_, args) + local action = agent_subcommand_calls[args[1]] + if not action then + invalid_arguments('Invalid agent subcommand. Use: plan, build, select') + end + + return action() +end + +return M diff --git a/lua/opencode/commands/handlers/diff.lua b/lua/opencode/commands/handlers/diff.lua new file mode 100644 index 00000000..a65ec212 --- /dev/null +++ b/lua/opencode/commands/handlers/diff.lua @@ -0,0 +1,269 @@ +local core = require('opencode.core') +local git_review = require('opencode.git_review') +local session_store = require('opencode.session') +local state = require('opencode.state') + +local M = { + actions = {}, + handlers = {}, +} + +---@param message string +local function invalid_arguments(message) + error({ + code = 'invalid_arguments', + message = message, + }, 0) +end + +---@param from_snapshot_id? string +---@param _to_snapshot_id? string|number +function M.actions.diff_open(from_snapshot_id, _to_snapshot_id) + core.open_if_closed({ new_session = false, focus = 'output' }):and_then(function() + git_review.review(from_snapshot_id) + end) +end + +function M.actions.diff_next() + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.next_diff() + end) +end + +function M.actions.diff_prev() + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.prev_diff() + end) +end + +function M.actions.diff_close() + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.close_diff() + end) +end + +---@param from_snapshot_id? string +function M.actions.diff_revert_all(from_snapshot_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.revert_all(from_snapshot_id) + end) +end + +---@param from_snapshot_id? string +---@param _to_snapshot_id? string +function M.actions.diff_revert_selected_file(from_snapshot_id, _to_snapshot_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.revert_selected_file(from_snapshot_id) + end) +end + +function M.actions.diff_revert_all_last_prompt() + core.open({ new_session = false, focus = 'output' }):and_then(function() + local snapshots = session_store.get_message_snapshot_ids(state.current_message) + local snapshot_id = snapshots and snapshots[1] + if not snapshot_id then + vim.notify('No snapshots found for the current message', vim.log.levels.WARN) + return + end + git_review.revert_all(snapshot_id) + end) +end + +---@param snapshot_id? string +function M.actions.diff_revert_this(snapshot_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.revert_current(snapshot_id) + end) +end + +function M.actions.diff_revert_this_last_prompt() + core.open({ new_session = false, focus = 'output' }):and_then(function() + local snapshots = session_store.get_message_snapshot_ids(state.current_message) + local snapshot_id = snapshots and snapshots[1] + if not snapshot_id then + vim.notify('No snapshots found for the current message', vim.log.levels.WARN) + return + end + git_review.revert_current(snapshot_id) + end) +end + +---@param restore_point_id? string +function M.actions.diff_restore_snapshot_file(restore_point_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.restore_snapshot_file(restore_point_id) + end) +end + +---@param restore_point_id? string +function M.actions.diff_restore_snapshot_all(restore_point_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.restore_snapshot_all(restore_point_id) + end) +end + +function M.actions.set_review_breakpoint() + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.create_snapshot() + end) +end + +---@param args string[] +---@return { subcommand: string } +local function normalize_diff_args(args) + return { + subcommand = args[1] or 'open', + } +end + +---@param normalized { subcommand: string } +---@return OpencodeCommandDispatchError|nil +local function validate_diff_args(normalized) + if normalized.subcommand == 'open' or normalized.subcommand == 'next' then + return nil + end + + if normalized.subcommand == 'prev' or normalized.subcommand == 'close' then + return nil + end + + return { + code = 'invalid_arguments', + message = 'Invalid diff subcommand. Use: open, next, prev, close', + } +end + +---@param args string[] +---@return { scope: string|nil, target: string|nil } +local function normalize_revert_args(args) + return { + scope = args[1], + target = args[2], + } +end + +---@param normalized { scope: string|nil, target: string|nil } +---@return OpencodeCommandDispatchError|nil +local function validate_revert_args(normalized) + if normalized.scope ~= 'all' and normalized.scope ~= 'this' then + return { + code = 'invalid_arguments', + message = 'Invalid revert scope. Use: all or this', + } + end + + if not normalized.target then + return { + code = 'invalid_arguments', + message = 'Invalid revert target. Use: prompt, session, or ', + } + end + + return nil +end + +---@param args string[] +---@return { scope: string|nil, snapshot_id: string|nil } +local function normalize_restore_args(args) + return { + scope = args[1], + snapshot_id = args[2], + } +end + +---@param normalized { scope: string|nil, snapshot_id: string|nil } +---@return OpencodeCommandDispatchError|nil +local function validate_restore_args(normalized) + if not normalized.snapshot_id then + return { + code = 'invalid_arguments', + message = 'Snapshot ID required', + } + end + + if normalized.scope ~= 'file' and normalized.scope ~= 'all' then + return { + code = 'invalid_arguments', + message = 'Invalid restore scope. Use: file or all', + } + end + + return nil +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.diff(_, args) + local normalized = normalize_diff_args(args) + local validation_error = validate_diff_args(normalized) + if validation_error then + invalid_arguments(validation_error.message) + end + + if normalized.subcommand == 'next' then + return M.actions.diff_next() + end + + if normalized.subcommand == 'prev' then + return M.actions.diff_prev() + end + + if normalized.subcommand == 'close' then + return M.actions.diff_close() + end + + return M.actions.diff_open() +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.revert(_, args) + local normalized = normalize_revert_args(args) + local validation_error = validate_revert_args(normalized) + if validation_error then + invalid_arguments(validation_error.message) + end + + if normalized.scope == 'all' then + if normalized.target == 'prompt' then + return M.actions.diff_revert_all_last_prompt() + end + if normalized.target == 'session' then + return M.actions.diff_revert_all(nil) + end + return M.actions.diff_revert_all(normalized.target) + end + + if normalized.target == 'prompt' then + return M.actions.diff_revert_this_last_prompt() + end + + if normalized.target == 'session' then + return M.actions.diff_revert_this(nil) + end + + return M.actions.diff_revert_this(normalized.target) +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.restore(_, args) + local normalized = normalize_restore_args(args) + local validation_error = validate_restore_args(normalized) + if validation_error then + invalid_arguments(validation_error.message) + end + + if normalized.scope == 'file' then + return M.actions.diff_restore_snapshot_file(normalized.snapshot_id) + end + + return M.actions.diff_restore_snapshot_all(normalized.snapshot_id) +end + +---@param _ OpencodeCommandApi +function M.handlers.breakpoint(_) + return M.actions.set_review_breakpoint() +end + +return M diff --git a/lua/opencode/commands/handlers/permission.lua b/lua/opencode/commands/handlers/permission.lua new file mode 100644 index 00000000..5acd5ca7 --- /dev/null +++ b/lua/opencode/commands/handlers/permission.lua @@ -0,0 +1,95 @@ +local state = require('opencode.state') + +local M = { + actions = {}, + handlers = {}, +} + +---@param answer? 'once'|'always'|'reject' +---@param permission? OpencodePermission +function M.actions.respond_to_permission(answer, permission) + answer = answer or 'once' + + local permission_window = require('opencode.ui.permission_window') + local current_permission = permission or permission_window.get_current_permission() + if not current_permission then + vim.notify('No permission request to accept', vim.log.levels.WARN) + return + end + + state.api_client + :respond_to_permission(current_permission.sessionID, current_permission.id, { response = answer }) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to reply to permission: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + +---@param permission? OpencodePermission +function M.actions.permission_accept(permission) + M.actions.respond_to_permission('once', permission) +end + +---@param permission? OpencodePermission +function M.actions.permission_accept_all(permission) + M.actions.respond_to_permission('always', permission) +end + +---@param permission? OpencodePermission +function M.actions.permission_deny(permission) + M.actions.respond_to_permission('reject', permission) +end + +function M.actions.question_answer() + local question_window = require('opencode.ui.question_window') + local question_info = question_window.get_current_question_info() + if question_info and question_info.options and question_info.options[1] then + question_window._answer_with_option(1) + end +end + +function M.actions.question_other() + local question_window = require('opencode.ui.question_window') + if question_window.has_question() then + question_window._answer_with_custom() + end +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.permission(_, args) + local subcmd = args[1] + local index = tonumber(args[2]) + local permission = nil + + if index then + local permission_window = require('opencode.ui.permission_window') + local permissions = permission_window.get_all_permissions() + if not permissions or not permissions[index] then + error({ + code = 'invalid_arguments', + message = 'Invalid permission index: ' .. tostring(index), + }, 0) + end + + permission = permissions[index] + end + + if subcmd == 'accept' then + M.actions.permission_accept(permission) + return + end + + if subcmd == 'accept_all' then + M.actions.permission_accept_all(permission) + return + end + + if subcmd == 'deny' then + M.actions.permission_deny(permission) + return + end +end + +return M diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua new file mode 100644 index 00000000..e0e9feaf --- /dev/null +++ b/lua/opencode/commands/handlers/session.lua @@ -0,0 +1,320 @@ +local M = { + actions = {}, + handlers = {}, +} + +---@param message string +local function invalid_arguments(message) + error({ + code = 'invalid_arguments', + message = message, + }, 0) +end + +local function core() + return require('opencode.core') +end + +local function state() + return require('opencode.state') +end + +local function session_store() + return require('opencode.session') +end + +local function Promise() + return require('opencode.promise') +end + +local function window_actions() + return require('opencode.commands.handlers.window').actions +end + +function M.actions.open_input_new_session() + return core().open({ new_session = true, focus = 'input', start_insert = true }) +end + +---@param title string +function M.actions.open_input_new_session_with_title(title) + return Promise().async(function(session_title) + local new_session = core().create_new_session(session_title):await() + if not new_session then + vim.notify('Failed to create new session', vim.log.levels.ERROR) + return + end + + state().session.set_active(new_session) + return window_actions().open_input() + end)(title) +end + +---@param parent_id? string +function M.actions.select_session(parent_id) + core().select_session(parent_id) +end + +function M.actions.select_child_session() + local active = state().active_session + core().select_session(active and active.id or nil) +end + +---@param current_session? Session +function M.actions.compact_session(current_session) + local state_obj = state() + current_session = current_session or state_obj.active_session + if not current_session then + vim.notify('No active session to compact', vim.log.levels.WARN) + return + end + + local current_model = state_obj.current_model + if not current_model then + vim.notify('No model selected', vim.log.levels.ERROR) + return + end + + local providerId, modelId = current_model:match('^(.-)/(.+)$') + if not providerId or not modelId then + vim.notify('Invalid model format: ' .. tostring(current_model), vim.log.levels.ERROR) + return + end + + state_obj.api_client + :summarize_session(current_session.id, { + providerID = providerId, + modelID = modelId, + }) + :and_then(function() + vim.schedule(function() + vim.notify('Session compacted successfully', vim.log.levels.INFO) + end) + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to compact session: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + +function M.actions.share() + local state_obj = state() + if not state_obj.active_session then + vim.notify('No active session to share', vim.log.levels.WARN) + return + end + + state_obj.api_client + :share_session(state_obj.active_session.id) + :and_then(function(response) + vim.schedule(function() + if response and response.share and response.share.url then + vim.fn.setreg('+', response.share.url) + vim.notify('Session link copied to clipboard successfully: ' .. response.share.url, vim.log.levels.INFO) + else + vim.notify('Session shared but no link received', vim.log.levels.WARN) + end + end) + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to share session: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + +function M.actions.unshare() + local state_obj = state() + if not state_obj.active_session then + vim.notify('No active session to unshare', vim.log.levels.WARN) + return + end + + state_obj.api_client + :unshare_session(state_obj.active_session.id) + :and_then(function() + vim.schedule(function() + vim.notify('Session unshared successfully', vim.log.levels.INFO) + end) + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to unshare session: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + +function M.actions.initialize() + return Promise().async(function() + local id = require('opencode.id') + local state_obj = state() + local core_obj = core() + + local new_session = core_obj.create_new_session('AGENTS.md Initialization'):await() + if not new_session then + vim.notify('Failed to create new session', vim.log.levels.ERROR) + return + end + + if not core_obj.initialize_current_model():await() or not state_obj.current_model then + vim.notify('No model selected', vim.log.levels.ERROR) + return + end + + local providerId, modelId = state_obj.current_model:match('^(.-)/(.+)$') + if not providerId or not modelId then + vim.notify('Invalid model format: ' .. tostring(state_obj.current_model), vim.log.levels.ERROR) + return + end + + state_obj.session.set_active(new_session) + window_actions().open_input() + state_obj.api_client:init_session(state_obj.active_session.id, { + providerID = providerId, + modelID = modelId, + messageID = id.ascending('message'), + }) + end)() +end + +---@param current_session? Session +---@param new_title? string +function M.actions.rename_session(current_session, new_title) + return Promise().async(function(session_obj, requested_title) + local promise = Promise().new() + local state_obj = state() + session_obj = session_obj or (state_obj.active_session and vim.deepcopy(state_obj.active_session) or nil) --[[@as Session]] + if not session_obj then + vim.notify('No active session to rename', vim.log.levels.WARN) + promise:resolve(nil) + return promise + end + + local function rename_session_with_title(title) + state_obj.api_client + :update_session(session_obj.id, { title = title }) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to rename session: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) + :and_then(Promise().async(function() + session_obj.title = title + if state_obj.active_session and state_obj.active_session.id == session_obj.id then + local persisted_session = session_store().get_by_id(session_obj.id):await() + if persisted_session then + persisted_session.title = title + state_obj.session.set_active(vim.deepcopy(persisted_session)) + end + end + promise:resolve(session_obj) + end)) + end + + if requested_title and requested_title ~= '' then + rename_session_with_title(requested_title) + return promise + end + + vim.schedule(function() + vim.ui.input({ prompt = 'New session name: ', default = session_obj.title or '' }, function(input) + if input and input ~= '' then + rename_session_with_title(input) + else + promise:resolve(nil) + end + end) + end) + + return promise + end)(current_session, new_title) +end + +---@param args string[] +---@param start_idx integer +---@return string|nil +local function parse_title(args, start_idx) + local title = table.concat(vim.list_slice(args, start_idx), ' ') + if title == '' then + return nil + end + + return title +end + +---@param args string[] +---@return { subcommand: string|nil, title: string|nil } +local function normalize_session_args(args) + return { + subcommand = args[1], + title = parse_title(args, 2), + } +end + +---@param normalized { subcommand: string|nil, title: string|nil } +---@return OpencodeCommandDispatchError|nil +local function validate_session_args(normalized) + local subcmd = normalized.subcommand + if subcmd == 'new' or subcmd == 'rename' then + return nil + end + + if subcmd == 'select' or subcmd == 'child' or subcmd == 'compact' or subcmd == 'share' or subcmd == 'unshare' then + return nil + end + + if subcmd == 'agents_init' then + return nil + end + + return { + code = 'invalid_arguments', + message = 'Invalid session subcommand. Use: new, select, child, compact, share, unshare, agents_init, rename', + } +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.session(_, args) + local normalized = normalize_session_args(args) + local validation_error = validate_session_args(normalized) + if validation_error then + invalid_arguments(validation_error.message) + end + + if normalized.subcommand == 'new' then + if normalized.title then + return M.actions.open_input_new_session_with_title(normalized.title) + end + + return M.actions.open_input_new_session() + end + + if normalized.subcommand == 'rename' then + return M.actions.rename_session(nil, normalized.title) + end + + if normalized.subcommand == 'select' then + return M.actions.select_session() + end + + if normalized.subcommand == 'child' then + return M.actions.select_child_session() + end + + if normalized.subcommand == 'compact' then + return M.actions.compact_session() + end + + if normalized.subcommand == 'share' then + return M.actions.share() + end + + if normalized.subcommand == 'unshare' then + return M.actions.unshare() + end + + return M.actions.initialize() +end + +return M diff --git a/lua/opencode/commands/handlers/window.lua b/lua/opencode/commands/handlers/window.lua new file mode 100644 index 00000000..47f8084f --- /dev/null +++ b/lua/opencode/commands/handlers/window.lua @@ -0,0 +1,198 @@ +local core = require('opencode.core') +local state = require('opencode.state') +local ui = require('opencode.ui.ui') +local config = require('opencode.config') +local Promise = require('opencode.promise') +local input_window = require('opencode.ui.input_window') + +local M = { + actions = {}, + handlers = {}, +} + +---@param message string +local function invalid_arguments(message) + error({ + code = 'invalid_arguments', + message = message, + }, 0) +end + +function M.actions.open_input() + return core.open({ new_session = false, focus = 'input', start_insert = true }) +end + +function M.actions.open_output() + return core.open({ new_session = false, focus = 'output' }) +end + +function M.actions.close() + if state.display_route then + state.ui.clear_display_route() + ui.clear_output() + ui.render_output() + return + end + + ui.teardown_visible_windows(state.windows) +end + +function M.actions.hide() + ui.hide_visible_windows(state.windows) +end + +---@return {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} +function M.actions.get_window_state() + return state.ui.get_window_state() +end + +function M.actions.cancel() + core.cancel() +end + +---@param hidden OpencodeHiddenBuffers|nil +---@return 'input'|'output' +local function resolve_hidden_focus(hidden) + if hidden and (hidden.focused_window == 'input' or hidden.focused_window == 'output') then + return hidden.focused_window + end + + if hidden and hidden.input_hidden then + return 'output' + end + + return 'input' +end + +---@param restore_hidden boolean +---@return {focus: 'input'|'output', open_action: 'reuse_visible'|'restore_hidden'|'create_fresh'} +local function build_toggle_open_context(restore_hidden) + if restore_hidden then + local hidden = state.ui.inspect_hidden_buffers() + return { + focus = resolve_hidden_focus(hidden), + open_action = 'restore_hidden', + } + end + + local focus = config.ui.input.auto_hide and 'input' or state.last_focused_opencode_window or 'input' + + return { + focus = focus, + open_action = 'create_fresh', + } +end + +M.actions.toggle = Promise.async(function(new_session) + local decision = state.ui.resolve_toggle_decision(config.ui.persist_state, state.display_route ~= nil) + local action = decision.action + local is_new_session = new_session == true + + local function open_windows(restore_hidden) + local ctx = build_toggle_open_context(restore_hidden == true) + return core + .open({ + new_session = is_new_session, + focus = ctx.focus, + start_insert = false, + open_action = ctx.open_action, + }) + :await() + end + + local function open_fresh_windows() + return open_windows(false) + end + + local function restore_hidden_windows() + return open_windows(true) + end + + local function migrate_windows() + if state.windows then + ui.teardown_visible_windows(state.windows) + end + return open_fresh_windows() + end + + local action_handlers = { + close = M.actions.close, + hide = M.actions.hide, + close_hidden = ui.drop_hidden_snapshot, + migrate = migrate_windows, + restore_hidden = restore_hidden_windows, + open = open_fresh_windows, + } + + local handler = action_handlers[action] or action_handlers.open + return handler() +end) + +---@param new_session boolean? +function M.actions.toggle_focus(new_session) + if not ui.is_opencode_focused() then + local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output' + core.open({ new_session = new_session == true, focus = focus }) + else + ui.return_to_last_code_win() + end +end + +function M.actions.toggle_pane() + ui.toggle_pane() +end + +function M.actions.toggle_zoom() + ui.toggle_zoom() +end + +function M.actions.toggle_input() + input_window.toggle() +end + +function M.actions.swap_position() + local new_pos = (config.ui.position == 'left') and 'right' or 'left' + config.values.ui.position = new_pos + + if state.windows then + ui.close_windows(state.windows, false) + end + + vim.schedule(function() + M.actions.toggle(state.active_session == nil) + end) +end + +function M.actions.focus_input() + ui.focus_input({ restore_position = true, start_insert = true }) +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.open(_, args) + local target = args[1] or 'input' + if target == 'input' then + return M.actions.open_input() + end + + if target == 'output' then + return M.actions.open_output() + end + + invalid_arguments('Invalid target. Use: input or output') +end + +M.handlers.close = M.actions.close +M.handlers.hide = M.actions.hide +M.handlers.cancel = M.actions.cancel +M.handlers.toggle = M.actions.toggle +M.handlers.toggle_focus = M.actions.toggle_focus +M.handlers.toggle_pane = M.actions.toggle_pane +M.handlers.toggle_zoom = M.actions.toggle_zoom +M.handlers.toggle_input = M.actions.toggle_input + +function M.handlers.swap() + return M.actions.swap_position() +end + +return M diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua new file mode 100644 index 00000000..01f31cd6 --- /dev/null +++ b/lua/opencode/commands/handlers/workflow.lua @@ -0,0 +1,695 @@ +local core = require('opencode.core') +local util = require('opencode.util') +local config_file = require('opencode.config_file') +local state = require('opencode.state') +local quick_chat = require('opencode.quick_chat') +local history = require('opencode.history') +local window_handler = require('opencode.commands.handlers.window') +local config = require('opencode.config') +local Promise = require('opencode.promise') +local input_window = require('opencode.ui.input_window') +local ui = require('opencode.ui.ui') +local nvim = vim['api'] + +local M = { + actions = {}, + handlers = {}, +} + +---@param prompt string +---@param opts? SendMessageOpts +function M.actions.run(prompt, opts) + opts = vim.tbl_deep_extend('force', { new_session = false, focus = 'output' }, opts or {}) + return core.open(opts):and_then(function() + return core.send_message(prompt, opts) + end) +end + +---@param prompt string +---@param opts? SendMessageOpts +function M.actions.run_new_session(prompt, opts) + opts = vim.tbl_deep_extend('force', { new_session = true, focus = 'output' }, opts or {}) + return core.open(opts):and_then(function() + return core.send_message(prompt, opts) + end) +end + +---@param message string|string[]|nil +---@param range? OpencodeSelectionRange +function M.actions.quick_chat(message, range) + if not range then + if vim.fn.mode():match('[vV\022]') then + local visual_range = util.get_visual_range() + if visual_range then + range = { + start = visual_range.start_line, + stop = visual_range.end_line, + } + end + end + end + + if type(message) == 'table' then + message = table.concat(message, ' ') + end + + if not message or #message == 0 then + local scope = range and ('[selection: ' .. range.start .. '-' .. range.stop .. ']') + or '[line: ' .. tostring(nvim.nvim_win_get_cursor(0)[1]) .. ']' + vim.ui.input({ prompt = 'Quick Chat Message: ' .. scope, win = { relative = 'cursor' } }, function(input) + if input and input ~= '' then + local prompt, ctx = util.parse_quick_context_args(input) + quick_chat.quick_chat(prompt, { context_config = ctx }, range) + end + end) + return + end + + local prompt, ctx = util.parse_quick_context_args(message) + quick_chat.quick_chat(prompt, { context_config = ctx }, range) +end + +function M.actions.select_history() + require('opencode.ui.history_picker').pick() +end + +function M.actions.prev_history() + if not state.ui.is_visible() then + return + end + + local prev_prompt = history.prev() + if prev_prompt then + input_window.set_content(prev_prompt) + require('opencode.ui.mention').restore_mentions(state.windows.input_buf) + end +end + +function M.actions.next_history() + if not state.ui.is_visible() then + return + end + + local next_prompt = history.next() + if next_prompt then + input_window.set_content(next_prompt) + require('opencode.ui.mention').restore_mentions(state.windows.input_buf) + end +end + +function M.actions.prev_prompt_history() + local key = config.get_key_for_function('input_window', 'prev_prompt_history') + if key ~= '' then + return M.actions.prev_history() + end + + local current_line = nvim.nvim_win_get_cursor(0)[1] + local at_boundary = current_line <= 1 + + if at_boundary then + return M.actions.prev_history() + end + + nvim.nvim_feedkeys(nvim.nvim_replace_termcodes(key, true, false, true), 'n', false) +end + +function M.actions.next_prompt_history() + local key = config.get_key_for_function('input_window', 'next_prompt_history') + if key ~= '' then + return M.actions.next_history() + end + + local current_line = nvim.nvim_win_get_cursor(0)[1] + local at_boundary = current_line >= nvim.nvim_buf_line_count(0) + + if at_boundary then + return M.actions.next_history() + end + + nvim.nvim_feedkeys(nvim.nvim_replace_termcodes(key, true, false, true), 'n', false) +end + +function M.actions.references() + require('opencode.ui.reference_picker').pick() +end + +function M.actions.debug_output() + if not config.debug.enabled then + vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) + return + end + + local debug_helper = require('opencode.ui.debug_helper') + debug_helper.debug_output() +end + +function M.actions.debug_message() + if not config.debug.enabled then + vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) + return + end + + local debug_helper = require('opencode.ui.debug_helper') + debug_helper.debug_message() +end + +function M.actions.debug_session() + if not config.debug.enabled then + vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) + return + end + + local debug_helper = require('opencode.ui.debug_helper') + debug_helper.debug_session() +end + +function M.actions.paste_image() + core.paste_image_from_clipboard() +end + +M.actions.submit_input_prompt = Promise.async(function() + if state.display_route then + state.ui.clear_display_route() + ui.render_output(true) + end + + local message_sent = input_window.handle_submit() + if message_sent and config.ui.input.auto_hide and not input_window.is_hidden() then + input_window._hide() + end +end) + +function M.actions.mention() + local char = config.get_key_for_function('input_window', 'mention') + + ui.focus_input({ restore_position = false, start_insert = true }) + nvim.nvim_feedkeys(nvim.nvim_replace_termcodes(char, true, false, true), 'n', false) +end + +function M.actions.mention_file() + local picker = require('opencode.ui.file_picker') + local context = require('opencode.context') + require('opencode.ui.mention').mention(function(mention_cb) + picker.pick(function(file) + mention_cb(file.path) + context.add_file(file.path) + end) + end) +end + +function M.actions.context_items() + local char = config.get_key_for_function('input_window', 'context_items') + ui.focus_input({ restore_position = false, start_insert = true }) + nvim.nvim_feedkeys(nvim.nvim_replace_termcodes(char, true, false, true), 'n', false) +end + +function M.actions.slash_commands() + local char = config.get_key_for_function('input_window', 'slash_commands') + ui.focus_input({ restore_position = false, start_insert = true }) + nvim.nvim_feedkeys(nvim.nvim_replace_termcodes(char, true, false, true), 'n', false) +end + +M.actions.mcp = Promise.async(function() + local mcp_picker = require('opencode.ui.mcp_picker') + mcp_picker.pick() +end) + +---@param lines string[] +---@param show_welcome? boolean +---@return string[] +function M.actions.with_header(lines, show_welcome) + show_welcome = show_welcome or false + state.ui.set_display_route('/header') + + local msg = { + '## Opencode.nvim', + '', + ' █▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀', + ' █░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀', + ' ▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀', + '', + } + + if show_welcome then + table.insert( + msg, + 'Welcome to Opencode.nvim! This plugin allows you to interact with AI models directly from Neovim.' + ) + table.insert(msg, '') + end + + for _, line in ipairs(lines) do + table.insert(msg, line) + end + + return msg +end + +function M.actions.help() + state.ui.set_display_route('/help') + window_handler.actions.open_input() + local msg = M.actions.with_header({ + '### Available Commands', + '', + 'Use `:Opencode ` to run commands. Examples:', + '', + '- `:Opencode open input` - Open the input window', + '- `:Opencode session new` - Create a new session', + '- `:Opencode diff open` - Open diff view', + '', + '### Subcommands', + '', + '| Command | Description |', + '|--------------|-------------|', + }, false) + + if not state.ui.is_visible() or not state.windows.output_win then + return + end + + local max_desc_length = math.min(90, nvim.nvim_win_get_width(state.windows.output_win) - 35) + + local commands = require('opencode.registry').get_commands() + local sorted_commands = vim.tbl_keys(commands) + table.sort(sorted_commands) + + for _, name in ipairs(sorted_commands) do + local def = commands[name] + local desc = def.desc or '' + if #desc > max_desc_length then + desc = desc:sub(1, max_desc_length - 3) .. '...' + end + table.insert(msg, string.format('| %-12s | %-' .. max_desc_length .. 's |', name, desc)) + end + + table.insert(msg, '') + table.insert(msg, 'For slash commands (e.g., /models, /help), type `/` in the input window.') + table.insert(msg, '') + ui.render_lines(msg) +end + +M.actions.commands_list = Promise.async(function() + local commands = config_file.get_user_commands():await() + if not commands then + vim.notify('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) + return + end + + state.ui.set_display_route('/commands') + window_handler.actions.open_input() + + local msg = M.actions.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 +---@param args? string[] +M.actions.run_user_command = Promise.async(function(name, args) + return window_handler.actions.open_input():and_then(function() + local user_commands = config_file.get_user_commands():await() + local command_cfg = user_commands and user_commands[name] + if not command_cfg then + vim.notify('Unknown user command: ' .. name, vim.log.levels.WARN) + return + end + + local model = command_cfg.model or state.current_model + local agent = command_cfg.agent or state.current_mode + + if not state.active_session then + vim.notify('No active session', vim.log.levels.WARN) + return + end + + state.api_client + :send_command(state.active_session.id, { + command = name, + arguments = table.concat(args or {}, ' '), + model = model, + agent = agent, + }) + :and_then(function() + vim.schedule(function() + history.write('/' .. name .. ' ' .. table.concat(args or {}, ' ')) + end) + end) + end) --[[@as Promise ]] +end) + +---@param messageId? string +function M.actions.undo(messageId) + if not state.active_session then + vim.notify('No active session to undo', vim.log.levels.WARN) + return + end + + local message_to_revert = messageId or (state.last_user_message and state.last_user_message.info.id) + if not message_to_revert then + vim.notify('No user message to undo', vim.log.levels.WARN) + return + end + + state.api_client + :revert_message(state.active_session.id, { + messageID = message_to_revert, + }) + :and_then(function() + vim.schedule(function() + vim.cmd('checktime') + end) + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to undo last message: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + +local function find_next_message_for_redo() + if not state.active_session then + return nil + end + + local revert_time = 0 + local revert = state.active_session.revert + if not revert then + return nil + end + + for _, message in ipairs(state.messages or {}) do + if message.info.id == revert.messageID then + revert_time = math.floor(message.info.time.created) + break + end + if revert.partID and revert.partID ~= '' then + for _, part in ipairs(message.parts) do + if part.id == revert.partID and part.state and part.state.time then + revert_time = math.floor(part.state.time.start) + break + end + end + end + end + + local next_message_id = nil + for _, msg in ipairs(state.messages or {}) do + if msg.info.role == 'user' and msg.info.time.created > revert_time then + next_message_id = msg.info.id + break + end + end + + return next_message_id +end + +function M.actions.redo() + if not state.active_session then + vim.notify('No active session to redo', vim.log.levels.WARN) + return + end + + if not state.active_session.revert or state.active_session.revert.messageID == '' then + vim.notify('Nothing to redo', vim.log.levels.WARN) + return + end + + if not state.messages then + return + end + + local next_message_id = find_next_message_for_redo() + if not next_message_id then + state.api_client + :unrevert_messages(state.active_session.id) + :and_then(function() + vim.schedule(function() + vim.cmd('checktime') + end) + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to redo message: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) + return + end + + state.api_client + :revert_message(state.active_session.id, { + messageID = next_message_id, + }) + :and_then(function() + vim.schedule(function() + vim.cmd('checktime') + end) + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to redo message: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + +function M.actions.timeline() + local user_messages = {} + for _, msg in ipairs(state.messages or {}) do + local parts = msg.parts or {} + local is_summary = #parts == 1 and parts[1].synthetic == true + if msg.info.role == 'user' and not is_summary then + table.insert(user_messages, msg) + end + end + + if #user_messages == 0 then + vim.notify('No user messages in the current session', vim.log.levels.WARN) + return + end + + local timeline_picker = require('opencode.ui.timeline_picker') + timeline_picker.pick(user_messages, function(selected_msg) + if selected_msg then + require('opencode.ui.navigation').goto_message_by_id(selected_msg.info.id) + end + end) +end + +function M.actions.next_message() + require('opencode.ui.navigation').goto_next_message() +end + +function M.actions.prev_message() + require('opencode.ui.navigation').goto_prev_message() +end + +---@param message_id? string +function M.actions.fork_session(message_id) + if not state.active_session then + vim.notify('No active session to fork', vim.log.levels.WARN) + return + end + + local message_to_fork = message_id or state.last_user_message and state.last_user_message.info.id + if not message_to_fork then + vim.notify('No user message to fork from', vim.log.levels.WARN) + return + end + + state.api_client + :fork_session(state.active_session.id, { + messageID = message_to_fork, + }) + :and_then(function(response) + vim.schedule(function() + if response and response.id then + vim.notify('Session forked successfully. New session ID: ' .. response.id, vim.log.levels.INFO) + core.switch_session(response.id) + else + vim.notify('Session forked but no new session ID received', vim.log.levels.WARN) + end + end) + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to fork session: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + +function M.actions.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.actions.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 + +M.actions.review = Promise.async(function(args) + local new_session = core.create_new_session('Code review checklist for diffs and PRs'):await() + if not new_session then + vim.notify('Failed to create new session', vim.log.levels.ERROR) + return + end + if not core.initialize_current_model():await() or not state.current_model then + vim.notify('No model selected', vim.log.levels.ERROR) + return + end + + local providerId, modelId = state.current_model:match('^(.-)/(.+)$') + if not providerId or not modelId then + vim.notify('Invalid model format: ' .. tostring(state.current_model), vim.log.levels.ERROR) + return + end + + state.session.set_active(new_session) + window_handler.actions.open_input():await() + state.api_client + :send_command(state.active_session.id, { + command = 'review', + arguments = table.concat(args or {}, ' '), + model = state.current_model, + }) + :and_then(function() + vim.schedule(function() + history.write('/review ' .. table.concat(args or {}, ' ')) + end) + end) +end) + +M.actions.add_visual_selection = Promise.async( + ---@param opts? {open_input?: boolean} + ---@param range OpencodeSelectionRange + function(opts, range) + opts = vim.tbl_extend('force', { open_input = true }, opts or {}) + local context = require('opencode.context') + local added = context.add_visual_selection(range) + + if added and opts.open_input then + window_handler.actions.open_input():await() + end + end +) + +M.actions.add_visual_selection_inline = Promise.async( + ---@param opts? {open_input?: boolean} + ---@param range OpencodeSelectionRange + function(opts, range) + opts = vim.tbl_extend('force', { open_input = true }, opts or {}) + local context = require('opencode.context') + local text = context.build_inline_selection_text(range) + + if not text then + return + end + + window_handler.actions.open_input():await() + input_window._append_to_input(text) + vim.schedule(function() + if vim.fn.mode() ~= 'n' then + nvim.nvim_feedkeys(nvim.nvim_replace_termcodes('', true, false, true), 'n', false) + end + end) + end +) + +---@param message string +local function invalid_arguments(message) + error({ + code = 'invalid_arguments', + message = message, + }, 0) +end + +M.handlers.help = M.actions.help +M.handlers.redo = M.actions.redo +M.handlers.mcp = M.actions.mcp +M.handlers.commands_list = M.actions.commands_list +M.handlers.timeline = M.actions.timeline +M.handlers.toggle_tool_output = M.actions.toggle_tool_output +M.handlers.toggle_reasoning_output = M.actions.toggle_reasoning_output +M.handlers.paste_image = M.actions.paste_image +M.handlers.references = M.actions.references + +---@param _ OpencodeCommandApi +function M.handlers.history(_) + return M.actions.select_history() +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.run(_, args) + local opts, prompt = util.parse_run_args(args) + if prompt == '' then + invalid_arguments('Prompt required') + end + + return M.actions.run(prompt, opts) +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.run_new(_, args) + local opts, prompt = util.parse_run_args(args) + if prompt == '' then + invalid_arguments('Prompt required') + end + + return M.actions.run_new_session(prompt, opts) +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.command(_, args) + local name = args[1] + if not name or name == '' then + invalid_arguments('Command name required') + end + + return M.actions.run_user_command(name, vim.list_slice(args, 2)) +end + +---@param _ OpencodeCommandApi +---@param args string[] +---@param range? OpencodeSelectionRange +function M.handlers.quick_chat(_, args, range) + return M.actions.quick_chat(args, range) +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.review(_, args) + return M.actions.review(args) +end + +---@param _ OpencodeCommandApi +---@param args string[] +function M.handlers.undo(_, args) + return M.actions.undo(args[1]) +end + +---@param _ OpencodeCommandApi +---@param args string[] +---@param range? OpencodeSelectionRange +function M.handlers.add_visual_selection(_, args, range) + return M.actions.add_visual_selection(args, range) +end + +return M diff --git a/lua/opencode/commands/init.lua b/lua/opencode/commands/init.lua new file mode 100644 index 00000000..b065425c --- /dev/null +++ b/lua/opencode/commands/init.lua @@ -0,0 +1,77 @@ +local registry = require('opencode.registry') +local completion_providers = require('opencode.commands.completion_providers') +local router = require('opencode.commands.router') + +local M = {} + +function M.route_command(opts) + return router.route_command(opts) +end + +function M.complete_command(arg_lead, cmd_line, _) + local commands = registry.get_commands() + local parts = vim.split(cmd_line, '%s+', { trimempty = false }) + local num_parts = #parts + + if num_parts <= 2 then + local subcommands = vim.tbl_keys(commands) + table.sort(subcommands) + return vim.tbl_filter(function(cmd) + return vim.startswith(cmd, arg_lead) + end, subcommands) + end + + local subcommand = parts[2] + local subcmd_def = commands[subcommand] + + if not subcmd_def then + return {} + end + + if num_parts <= 3 and subcmd_def.completions then + local completions = subcmd_def.completions + if type(completions) == 'function' then + completions = completions() or {} + end + + if type(completions) ~= 'table' then + return {} + end + + return vim.tbl_filter(function(opt) + return vim.startswith(opt, arg_lead) + end, completions) + end + + if num_parts <= 3 and subcmd_def.completion_provider_id then + local provider = completion_providers.get(subcmd_def.completion_provider_id) + + if not provider then + return {} + end + + local completions = provider() or {} + return vim.tbl_filter(function(opt) + return vim.startswith(opt, arg_lead) + end, completions) + end + + if num_parts <= 4 and subcmd_def.sub_completions then + return vim.tbl_filter(function(opt) + return vim.startswith(opt, arg_lead) + end, subcmd_def.sub_completions) + end + + return {} +end + +function M.setup() + vim.api.nvim_create_user_command('Opencode', M.route_command, { + desc = 'Opencode.nvim main command with nested subcommands', + nargs = '*', + range = true, + complete = M.complete_command, + }) +end + +return M diff --git a/lua/opencode/commands/parse.lua b/lua/opencode/commands/parse.lua new file mode 100644 index 00000000..f615b1cc --- /dev/null +++ b/lua/opencode/commands/parse.lua @@ -0,0 +1,106 @@ +local M = {} + +---@param opts OpencodeCommandRouteOpts +---@return OpencodeSelectionRange|nil +local function parse_range(opts) + if not (opts.range and opts.range > 0) then + return nil + end + + return { + start = opts.line1, + stop = opts.line2, + } +end + +---@param command_name string +---@param command_def OpencodeUICommand +---@param args string[] +---@return OpencodeCommandParseError|nil +local function validate_nested_subcommand(command_name, command_def, args) + local validation = command_def.nested_subcommand + if not validation then + return nil + end + + local nested_subcommand = args[1] + if not nested_subcommand then + if validation.allow_empty then + return nil + end + + return { + code = 'invalid_subcommand', + message = 'Invalid ' .. command_name .. ' subcommand. Use: ' .. table.concat(command_def.completions or {}, ', '), + subcommand = command_name, + } + end + + if vim.tbl_contains(command_def.completions or {}, nested_subcommand) then + return nil + end + + return { + code = 'invalid_subcommand', + message = 'Invalid ' .. command_name .. ' subcommand. Use: ' .. table.concat(command_def.completions or {}, ', '), + subcommand = command_name, + } +end + +---@param opts OpencodeCommandRouteOpts +---@param commands table +---@return OpencodeCommandParseResult +function M.command(opts, commands) + local raw_args = opts.args or '' + local argv = vim.split(raw_args, '%s+', { trimempty = true }) + local subcommand = #argv == 0 and 'toggle' or argv[1] + local subcmd_def = commands[subcommand] + local range = parse_range(opts) + + if not subcmd_def then + return { + ok = false, + error = { + code = 'unknown_subcommand', + message = 'Unknown subcommand: ' .. subcommand, + subcommand = subcommand, + }, + } + end + + if not subcmd_def.handler_id then + return { + ok = false, + error = { + code = 'missing_handler', + message = 'Command is missing handler: ' .. subcommand, + subcommand = subcommand, + }, + } + end + + local args = #argv == 0 and {} or vim.list_slice(argv, 2) + local nested_subcommand_error = validate_nested_subcommand(subcommand, subcmd_def, args) + if nested_subcommand_error then + return { + ok = false, + error = nested_subcommand_error, + } + end + + return { + ok = true, + intent = { + handler_id = subcmd_def.handler_id, + args = args, + range = range, + raw = { + args = raw_args, + argv = argv, + subcommand = subcommand, + }, + }, + } +end + +return M diff --git a/lua/opencode/commands/router.lua b/lua/opencode/commands/router.lua new file mode 100644 index 00000000..7adab120 --- /dev/null +++ b/lua/opencode/commands/router.lua @@ -0,0 +1,147 @@ +local registry = require('opencode.registry') +local command_parse = require('opencode.commands.parse') +local command_dispatch = require('opencode.commands.dispatch') + +local M = {} + +local api_method_command_aliases = { + open_input = { 'open', 'input' }, + open_output = { 'open', 'output' }, + open_input_new_session = { 'session', 'new' }, + select_history = { 'history' }, + select_session = { 'session', 'select' }, + select_child_session = { 'session', 'child' }, + compact_session = { 'session', 'compact' }, + share = { 'session', 'share' }, + unshare = { 'session', 'unshare' }, + initialize = { 'session', 'agents_init' }, + rename_session = { 'session', 'rename' }, + configure_provider = { 'models' }, + configure_variant = { 'variant' }, + select_agent = { 'agent', 'select' }, + agent_plan = { 'agent', 'plan' }, + agent_build = { 'agent', 'build' }, + swap_position = { 'swap' }, + run_new_session = { 'run_new' }, + set_review_breakpoint = { 'breakpoint' }, + diff_open = { 'diff', 'open' }, + diff_next = { 'diff', 'next' }, + diff_prev = { 'diff', 'prev' }, + diff_close = { 'diff', 'close' }, + diff_revert_all_last_prompt = { 'revert', 'all', 'prompt' }, + diff_revert_this_last_prompt = { 'revert', 'this', 'prompt' }, +} + +---@param args string[]|string|nil +---@return string[] +local function normalize_cli_args(args) + if type(args) == 'table' then + return vim.deepcopy(args) + end + + if args == nil or args == '' then + return {} + end + + return { tostring(args) } +end + +---@param api_method string +---@return OpencodeCommandDispatchError +local function invalid_command_error(api_method) + return { + code = 'invalid_command', + message = 'Unknown command method: ' .. api_method, + subcommand = api_method, + } +end + +---@param api? OpencodeCommandApi +---@return OpencodeCommandApi +local function resolve_api(api) + if api then + return api + end + return require('opencode.api') +end + +---@param opts? OpencodeCommandRouteOpts +---@param api? OpencodeCommandApi +---@return any +function M.route_command(opts, api) + local command_opts = opts or { args = '', range = 0 } + local parsed = command_parse.command(command_opts, registry.get_commands()) + local dispatched = command_dispatch.command(parsed, resolve_api(api)) + + if not dispatched.ok then + vim.notify(dispatched.error.message, vim.log.levels.ERROR) + return nil + end + + return dispatched.result +end + +---@param argv string[] +---@param range? OpencodeSelectionRange +---@param api? OpencodeCommandApi +---@return any +function M.route_command_argv(argv, range, api) + if type(argv) ~= 'table' or #argv == 0 then + return nil + end + + local command_opts = { + args = table.concat(argv, ' '), + range = 0, + } + + if range and range.start and range.stop then + command_opts.range = 2 + command_opts.line1 = range.start + command_opts.line2 = range.stop + end + + return M.route_command(command_opts, api) +end + +---@param api_method string +---@param args? string[]|string +---@return string[]|nil +function M.resolve_command_argv(api_method, args) + local extra_args = normalize_cli_args(args) + local base_argv = api_method_command_aliases[api_method] + if not base_argv then + local commands = registry.get_commands() + if not commands[api_method] then + return nil + end + base_argv = { api_method } + end + + local argv = vim.deepcopy(base_argv) + vim.list_extend(argv, extra_args) + return argv +end + +---@param api_method string +---@param args? string[]|string +---@return boolean +function M.can_route_api_method(api_method, args) + return M.resolve_command_argv(api_method, args) ~= nil +end + +---@param api OpencodeCommandApi +---@param api_method string +---@param args? string[]|string +---@param range? OpencodeSelectionRange +---@return boolean, any +function M.route_api_method(api, api_method, args, range) + local argv = M.resolve_command_argv(api_method, args) + if not argv then + return false, invalid_command_error(api_method) + end + + return true, M.route_command_argv(argv, range, api) +end + +return M diff --git a/lua/opencode/commands/slash.lua b/lua/opencode/commands/slash.lua new file mode 100644 index 00000000..7f4760fd --- /dev/null +++ b/lua/opencode/commands/slash.lua @@ -0,0 +1,76 @@ +local Promise = require('opencode.promise') +local config_file = require('opencode.config_file') +local registry = require('opencode.registry') +local router = require('opencode.commands.router') +local log = require('opencode.log') + +local M = {} + +---@param api OpencodeCommandApi +---@param slash_cmd string +---@param def OpencodeSlashCommandSpec +---@return OpencodeSlashCommand|nil +local function to_runtime_slash_command(api, slash_cmd, def) + local fn = def.fn + if not fn and def.api_method then + fn = function(args) + local routed, result = router.route_api_method(api, def.api_method, args) + if routed then + return result + end + + local err = result + or { + code = 'invalid_command', + message = 'Unknown command method: ' .. tostring(def.api_method), + subcommand = tostring(def.api_method), + } + vim.notify(err.message, vim.log.levels.ERROR) + return err + end + end + + if type(fn) ~= 'function' then + log.notify(string.format("Slash command '%s' has no executable handler", slash_cmd), vim.log.levels.WARN) + return nil + end + + return { + slash_cmd = slash_cmd, + desc = def.desc, + fn = fn, + args = def.args or false, + } +end + +M.get_commands = Promise.async(function() + local api = require('opencode.api') + local result = {} + + for slash_cmd, def in pairs(registry.get_slash_commands()) do + local runtime_def = to_runtime_slash_command(api, slash_cmd, def) + if runtime_def then + table.insert(result, runtime_def) + end + end + + local user_commands = config_file.get_user_commands():await() + 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(args) + local argv = { 'command', name } + vim.list_extend(argv, args or {}) + return router.route_command_argv(argv, nil, api) + end, + args = true, + }) + end + end + + return result +end) + +return M diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 9815756b..283926ab 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -267,6 +267,14 @@ M.defaults = { on_done_thinking = nil, on_permission_requested = nil, }, + extensions = { + builtin = { + commands = true, + slash = true, + }, + user = {}, + conflict_policy = 'error', + }, quick_chat = { default_model = nil, default_agent = nil, diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 7b3fbe0f..0cb397a2 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -7,10 +7,11 @@ function M.setup(opts) -- be set by the user local config = require('opencode.config') config.setup(opts) + require('opencode.registry').setup(config.extensions) require('opencode.ui.highlight').setup() require('opencode.core').setup() - require('opencode.api').setup() + require('opencode.commands').setup() require('opencode.ui.completion').setup() require('opencode.keymap').setup(config.keymap) require('opencode.event_manager').setup() diff --git a/lua/opencode/keymap.lua b/lua/opencode/keymap.lua index fa1f7fa4..d0f87492 100644 --- a/lua/opencode/keymap.lua +++ b/lua/opencode/keymap.lua @@ -1,4 +1,110 @@ local M = {} +local router = require('opencode.commands.router') + +---@type table +local legacy_keymap_aliases = { + next_history = 'next_prompt_history', + prev_history = 'prev_prompt_history', + run_user_command = 'command', + open_input_new_session_with_title = 'open_input_new_session', +} + +---@type table +local warned_legacy_keymap_aliases = {} + +---@param legacy_name string +---@param mapped_name string +local function warn_legacy_keymap_alias_once(legacy_name, mapped_name) + local key = legacy_name .. '->' .. mapped_name + if warned_legacy_keymap_aliases[key] then + return + end + + warned_legacy_keymap_aliases[key] = true + vim.notify( + string.format('Keymap action `%s` is deprecated; use `%s` instead', legacy_name, mapped_name), + vim.log.levels.WARN + ) +end + +---@type table +local non_command_callback_resolvers = { + add_visual_selection_inline = function(api) + return api.add_visual_selection_inline + end, + context_items = function(api) + return api.context_items + end, + cycle_variant = function(api) + return api.cycle_variant + end, + debug_message = function(api) + return api.debug_message + end, + debug_output = function(api) + return api.debug_output + end, + debug_session = function(api) + return api.debug_session + end, + diff_restore_snapshot_all = function(api) + return api.diff_restore_snapshot_all + end, + diff_restore_snapshot_file = function(api) + return api.diff_restore_snapshot_file + end, + diff_revert_all = function(api) + return api.diff_revert_all + end, + diff_revert_this = function(api) + return api.diff_revert_this + end, + focus_input = function(api) + return api.focus_input + end, + mention = function(api) + return api.mention + end, + mention_file = function(api) + return api.mention_file + end, + next_message = function(api) + return api.next_message + end, + next_prompt_history = function(api) + return api.next_prompt_history + end, + permission_accept = function(api) + return api.permission_accept + end, + permission_accept_all = function(api) + return api.permission_accept_all + end, + permission_deny = function(api) + return api.permission_deny + end, + prev_message = function(api) + return api.prev_message + end, + prev_prompt_history = function(api) + return api.prev_prompt_history + end, + question_answer = function(api) + return api.question_answer + end, + question_other = function(api) + return api.question_other + end, + slash_commands = function(api) + return api.slash_commands + end, + submit_input_prompt = function(api) + return api.submit_input_prompt + end, + switch_mode = function(api) + return api.switch_mode + end, +} local function is_completion_visible() return require('opencode.ui.completion').is_completion_visible() @@ -13,13 +119,63 @@ local function wrap_with_completion_check(key_binding, callback) end end +---@param callback function|nil +---@param func_args any +---@return function|nil +local function with_optional_args(callback, func_args) + if type(callback) ~= 'function' then + return nil + end + + if func_args ~= nil then + return function() + callback(func_args) + end + end + + return callback +end + +---@param api OpencodeCommandApi +---@param func_name string|function +---@param func_args any +---@return function|nil +local function resolve_callback(api, func_name, func_args) + if type(func_name) == 'function' then + return func_name + end + + if type(func_name) == 'string' then + local resolved_name = func_name + local mapped_name = legacy_keymap_aliases[func_name] + if mapped_name then + warn_legacy_keymap_alias_once(func_name, mapped_name) + resolved_name = mapped_name + end + + local command_argv = router.resolve_command_argv(resolved_name, func_args) + if command_argv then + return function() + router.route_command_argv(command_argv, nil, api) + end + end + + local resolver = non_command_callback_resolvers[resolved_name] + if resolver then + return with_optional_args(resolver(api), func_args) + end + end + + return nil +end + ---@param keymap_config table The keymap configuration table ---@param default_modes table Default modes for these keymaps ---@param base_opts table Base options to use for all keymaps ---@param defer_to_completion boolean? Whether to defer to completion engine when visible local function process_keymap_entry(keymap_config, default_modes, base_opts, defer_to_completion) local api = require('opencode.api') - local cmds = api.commands + local cmds = require('opencode.registry').get_commands() for key_binding, config_entry in pairs(keymap_config) do if config_entry == false then @@ -27,18 +183,12 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts, def elseif config_entry then local func_name = config_entry[1] local func_args = config_entry[2] - local raw_callback = type(func_name) == 'function' and func_name or api[func_name] - local callback = raw_callback - - if raw_callback and func_args then - callback = function() - raw_callback(func_args) - end - end + local callback = resolve_callback(api, func_name, func_args) + local resolved_name = legacy_keymap_aliases[func_name] or func_name local modes = config_entry.mode or default_modes local opts = vim.tbl_deep_extend('force', {}, base_opts) - opts.desc = config_entry.desc or cmds[func_name] and cmds[func_name].desc + opts.desc = config_entry.desc or cmds[resolved_name] and cmds[resolved_name].desc if callback then if defer_to_completion then diff --git a/lua/opencode/registry/builtin_commands.lua b/lua/opencode/registry/builtin_commands.lua new file mode 100644 index 00000000..8f132617 --- /dev/null +++ b/lua/opencode/registry/builtin_commands.lua @@ -0,0 +1,210 @@ +local M = {} + +local session_subcommands = { 'new', 'select', 'child', 'compact', 'share', 'unshare', 'agents_init', 'rename' } +local diff_subcommands = { 'open', 'next', 'prev', 'close' } +local agent_subcommands = { 'plan', 'build', 'select' } +local permission_subcommands = { 'accept', 'accept_all', 'deny' } + +---@return table +function M.get() + return { + open = { + desc = 'Open opencode window (input/output)', + completions = { 'input', 'output' }, + handler_id = 'open', + }, + + close = { + desc = 'Close opencode windows', + handler_id = 'close', + }, + + hide = { + desc = 'Hide opencode windows (preserve buffers for fast restore)', + handler_id = 'hide', + }, + + cancel = { + desc = 'Cancel running request', + handler_id = 'cancel', + }, + + toggle = { + desc = 'Toggle opencode windows', + handler_id = 'toggle', + }, + + toggle_focus = { + desc = 'Toggle focus between opencode and code', + handler_id = 'toggle_focus', + }, + + toggle_pane = { + desc = 'Toggle between input/output panes', + handler_id = 'toggle_pane', + }, + + toggle_zoom = { + desc = 'Toggle window zoom', + handler_id = 'toggle_zoom', + }, + + toggle_input = { + desc = 'Toggle input window visibility', + handler_id = 'toggle_input', + }, + + quick_chat = { + desc = 'Quick chat with current buffer or visual selection', + handler_id = 'quick_chat', + range = true, + nargs = '+', + complete = false, + }, + + swap = { + desc = 'Swap pane position left/right', + handler_id = 'swap', + }, + + review = { + desc = 'Review changes (commit/branch/pr), defaults to uncommitted changes', + handler_id = 'review', + nargs = '+', + }, + + session = { + desc = 'Manage sessions (new/select/child/compact/share/unshare/rename)', + completions = session_subcommands, + nested_subcommand = { allow_empty = false }, + handler_id = 'session', + }, + + undo = { + desc = 'Undo last action', + handler_id = 'undo', + }, + + redo = { + desc = 'Redo last action', + handler_id = 'redo', + }, + + diff = { + desc = 'View file diffs (open/next/prev/close)', + completions = diff_subcommands, + nested_subcommand = { allow_empty = true }, + handler_id = 'diff', + }, + + revert = { + desc = 'Revert changes (all/this, prompt/session)', + completions = { 'all', 'this' }, + sub_completions = { 'prompt', 'session' }, + handler_id = 'revert', + }, + + restore = { + desc = 'Restore from snapshot (file/all)', + completions = { 'file', 'all' }, + handler_id = 'restore', + }, + + breakpoint = { + desc = 'Set review breakpoint', + handler_id = 'breakpoint', + }, + + agent = { + desc = 'Manage agents (plan/build/select)', + completions = agent_subcommands, + nested_subcommand = { allow_empty = false }, + handler_id = 'agent', + }, + + models = { + desc = 'Switch provider/model', + handler_id = 'models', + }, + + variant = { + desc = 'Switch model variant', + handler_id = 'variant', + }, + + run = { + desc = 'Run prompt in current session', + handler_id = 'run', + }, + + run_new = { + desc = 'Run prompt in new session', + handler_id = 'run_new', + }, + + command = { + desc = 'Run user-defined command', + completion_provider_id = 'user_commands', + handler_id = 'command', + }, + + help = { + desc = 'Show this help message', + handler_id = 'help', + }, + + history = { + desc = 'Select from prompt history', + handler_id = 'history', + }, + + mcp = { + desc = 'Show MCP server configuration', + handler_id = 'mcp', + }, + + commands_list = { + desc = 'Show user-defined commands', + handler_id = 'commands_list', + }, + + permission = { + desc = 'Respond to permissions (accept/accept_all/deny)', + completions = permission_subcommands, + nested_subcommand = { allow_empty = false }, + handler_id = 'permission', + }, + + timeline = { + desc = 'Open timeline picker to navigate/undo/redo/fork to message', + handler_id = 'timeline', + }, + + toggle_tool_output = { + desc = 'Toggle tool output visibility in the output window', + handler_id = 'toggle_tool_output', + }, + + toggle_reasoning_output = { + desc = 'Toggle reasoning output visibility in the output window', + handler_id = 'toggle_reasoning_output', + }, + + paste_image = { + desc = 'Paste image from clipboard and add to context', + handler_id = 'paste_image', + }, + + references = { + desc = 'Browse code references from conversation', + handler_id = 'references', + }, + + add_visual_selection = { + desc = 'Add current visual selection to context', + handler_id = 'add_visual_selection', + }, + } +end + +return M diff --git a/lua/opencode/registry/extensions/commands.lua b/lua/opencode/registry/extensions/commands.lua new file mode 100644 index 00000000..9ff09f62 --- /dev/null +++ b/lua/opencode/registry/extensions/commands.lua @@ -0,0 +1,5 @@ +local builtin_commands = require('opencode.registry.builtin_commands') + +return { + commands = builtin_commands.get(), +} diff --git a/lua/opencode/registry/extensions/slash.lua b/lua/opencode/registry/extensions/slash.lua new file mode 100644 index 00000000..0098bff3 --- /dev/null +++ b/lua/opencode/registry/extensions/slash.lua @@ -0,0 +1,30 @@ +return { + slash_commands = { + ['/help'] = { api_method = 'help', desc = 'Show help message' }, + ['/agent'] = { api_method = 'select_agent', desc = 'Select agent mode' }, + ['/agents_init'] = { api_method = 'initialize', desc = 'Initialize AGENTS.md session' }, + ['/child-sessions'] = { api_method = 'select_child_session', desc = 'Select child session' }, + ['/command-list'] = { api_method = 'commands_list', desc = 'Show user-defined commands' }, + ['/compact'] = { api_method = 'compact_session', desc = 'Compact current session' }, + ['/history'] = { api_method = 'select_history', desc = 'Select from history' }, + ['/mcp'] = { api_method = 'mcp', desc = 'Show MCP server configuration' }, + ['/models'] = { api_method = 'configure_provider', desc = 'Switch provider/model' }, + ['/variant'] = { api_method = 'configure_variant', desc = 'Switch model variant' }, + ['/new'] = { api_method = 'open_input_new_session', desc = 'Create new session' }, + ['/redo'] = { api_method = 'redo', desc = 'Redo last action' }, + ['/sessions'] = { api_method = 'select_session', desc = 'Select session' }, + ['/share'] = { api_method = 'share', desc = 'Share current session' }, + ['/timeline'] = { api_method = 'timeline', desc = 'Open timeline picker' }, + ['/references'] = { api_method = 'references', desc = 'Browse code references from conversation' }, + ['/undo'] = { api_method = 'undo', desc = 'Undo last action' }, + ['/unshare'] = { api_method = 'unshare', desc = 'Unshare current session' }, + ['/rename'] = { api_method = 'rename_session', desc = 'Rename current session' }, + ['/thinking'] = { api_method = 'toggle_reasoning_output', desc = 'Toggle reasoning output' }, + ['/reasoning'] = { api_method = 'toggle_reasoning_output', desc = 'Toggle reasoning output' }, + ['/review'] = { + api_method = 'review', + desc = 'Review changes [commit|branch|pr], defaults to uncommitted changes', + args = true, + }, + }, +} diff --git a/lua/opencode/registry/init.lua b/lua/opencode/registry/init.lua new file mode 100644 index 00000000..dda84ec1 --- /dev/null +++ b/lua/opencode/registry/init.lua @@ -0,0 +1,175 @@ +local log = require('opencode.log') + +local M = {} + +---@type OpencodeRegistryCapabilityKey[] +local capability_keys = { + 'commands', + 'slash_commands', + 'handlers', + 'hooks', + 'context_sources', + 'completion_sources', + 'formatters', +} + +---@type table +local capability_kind_labels = { + commands = 'command', + slash_commands = 'slash command', + handlers = 'handler', + hooks = 'hook', + context_sources = 'context source', + completion_sources = 'completion source', + formatters = 'formatter', +} + +---@class OpencodeRegistryState +---@field capabilities table> +---@field owners table> +---@field conflict_policy OpencodeExtensionConflictPolicy +---@field initialized boolean +local state = { + capabilities = {}, + owners = {}, + conflict_policy = 'error', + initialized = false, +} + +local function reset_state(conflict_policy) + state.capabilities = {} + state.owners = {} + for _, key in ipairs(capability_keys) do + state.capabilities[key] = {} + state.owners[key] = {} + end + state.conflict_policy = conflict_policy or 'error' +end + +local function resolve_policy(opts) + if opts and opts.conflict_policy then + return opts.conflict_policy + end + + return state.conflict_policy +end + +local function merge_entries(bucket, owner_bucket, entries, extension_name, policy, kind) + if type(entries) ~= 'table' then + return + end + + for key, value in pairs(entries) do + local existing_owner = owner_bucket[key] + if existing_owner and policy == 'error' then + error( + string.format( + "Duplicate %s '%s' from extension '%s' (already registered by '%s')", + kind, + key, + extension_name, + existing_owner + ) + ) + end + + if not (existing_owner and policy == 'skip') then + bucket[key] = value + owner_bucket[key] = extension_name + end + end +end + +local function ensure_setup() + if state.initialized then + return + end + + local config = require('opencode.config') + M.setup(config.extensions) +end + +---@param extensions_cfg OpencodeExtensionsConfig|nil +function M.setup(extensions_cfg) + extensions_cfg = extensions_cfg or {} + reset_state(extensions_cfg.conflict_policy) + + local ok, loader = pcall(require, 'opencode.registry.loader') + if not ok then + log.notify('Failed to initialize registry loader: ' .. vim.inspect(loader), vim.log.levels.ERROR) + state.initialized = true + return + end + + loader.load(M, extensions_cfg) + state.initialized = true +end + +---@param name string +---@param spec OpencodeExtensionSpec +---@param opts? OpencodeExtensionRegisterOpts +function M.register(name, spec, opts) + if type(name) ~= 'string' or name == '' then + error('Extension name must be a non-empty string') + end + + if type(spec) ~= 'table' then + error(string.format("Extension '%s' must return a table spec", name)) + end + + local policy = resolve_policy(opts) + for _, key in ipairs(capability_keys) do + local entries = spec[key] + merge_entries(state.capabilities[key], state.owners[key], entries, name, policy, capability_kind_labels[key]) + end +end + +---@param capability OpencodeRegistryCapabilityKey +---@return table +function M.get_capability(capability) + ensure_setup() + + local bucket = state.capabilities[capability] + if not bucket then + error(string.format("Unknown registry capability '%s'", capability)) + end + + return bucket +end + +---@return table +function M.get_commands() + return M.get_capability('commands') +end + +---@return table +function M.get_slash_commands() + return M.get_capability('slash_commands') +end + +---@return OpencodeCommandHandlerMap +function M.get_handlers() + return M.get_capability('handlers') +end + +---@return table +function M.get_hooks() + return M.get_capability('hooks') +end + +---@return table +function M.get_context_sources() + return M.get_capability('context_sources') +end + +---@return table +function M.get_completion_sources() + return M.get_capability('completion_sources') +end + +---@return table +function M.get_formatters() + return M.get_capability('formatters') +end + +return M diff --git a/lua/opencode/registry/loader.lua b/lua/opencode/registry/loader.lua new file mode 100644 index 00000000..3cd2ab4d --- /dev/null +++ b/lua/opencode/registry/loader.lua @@ -0,0 +1,84 @@ +local log = require('opencode.log') + +local M = {} + +---@type table +local warned_messages = {} + +---@param key string +---@param message string +local function warn_once(key, message) + if warned_messages[key] then + return + end + + warned_messages[key] = true + log.notify(message, vim.log.levels.WARN) +end + +---@type { name: string, module: string }[] +local builtin_extensions = { + { name = 'commands', module = 'opencode.registry.extensions.commands' }, + { name = 'slash', module = 'opencode.registry.extensions.slash' }, +} + +local function load_extension(registry, name, module_path, conflict_policy) + local ok_require, spec = pcall(require, module_path) + if not ok_require then + log.notify( + string.format("Failed to load extension '%s' (%s): %s", name, module_path, vim.inspect(spec)), + vim.log.levels.ERROR + ) + return + end + + if type(spec) ~= 'table' then + log.notify( + string.format("Extension '%s' (%s) returned %s, expected table", name, module_path, type(spec)), + vim.log.levels.ERROR + ) + return + end + + if spec.handlers == nil and type(spec.usecases) == 'table' then + warn_once( + 'legacy_usecases:' .. module_path, + string.format("Extension '%s' (%s) uses deprecated `usecases`; rename it to `handlers`", name, module_path) + ) + spec.handlers = spec.usecases + end + + local ok_register, err = pcall(registry.register, name, spec, { + conflict_policy = conflict_policy, + source = module_path, + }) + + if not ok_register then + log.notify( + string.format("Failed to register extension '%s' (%s): %s", name, module_path, vim.inspect(err)), + vim.log.levels.ERROR + ) + end +end + +---@param registry table +---@param extensions_cfg OpencodeExtensionsConfig +function M.load(registry, extensions_cfg) + local conflict_policy = extensions_cfg.conflict_policy or 'error' + local builtin_cfg = extensions_cfg.builtin or {} + local user_cfg = extensions_cfg.user or {} + + for _, entry in ipairs(builtin_extensions) do + if builtin_cfg[entry.name] ~= false then + load_extension(registry, entry.name, entry.module, conflict_policy) + end + end + + for name, module_path in pairs(user_cfg) do + if module_path ~= false then + load_extension(registry, name, module_path, conflict_policy) + end + end +end + +return M From 13c15fa5b7eabd20684cd6dd46daf7a25ef987ba Mon Sep 17 00:00:00 2001 From: oujinsai Date: Fri, 20 Mar 2026 23:12:00 +0800 Subject: [PATCH 2/2] test(commands): add boundary contracts and guards Codify command-layer invariants so the refactor is reviewable and regression-safe. The tests lock parser/dispatcher behavior, handler boundaries, keymap routing, and registry contracts while tightening shared types used by those checks. --- lua/opencode/commands/handlers/agent.lua | 1 + lua/opencode/commands/handlers/diff.lua | 1 + lua/opencode/commands/handlers/permission.lua | 1 + lua/opencode/commands/handlers/session.lua | 67 ++-- lua/opencode/commands/handlers/window.lua | 1 + lua/opencode/commands/handlers/workflow.lua | 1 + lua/opencode/state/init.lua | 7 +- lua/opencode/types.lua | 299 +++++++++++++- tests/unit/api_spec.lua | 372 +++++++++++------- tests/unit/commands_dispatch_spec.lua | 352 +++++++++++++++++ tests/unit/commands_handlers_spec.lua | 228 +++++++++++ tests/unit/commands_parse_spec.lua | 96 +++++ tests/unit/commands_router_spec.lua | 72 ++++ tests/unit/entry_dependency_guard_spec.lua | 72 ++++ tests/unit/keymap_spec.lua | 194 +++++---- tests/unit/registry_loader_spec.lua | 309 +++++++++++++++ 16 files changed, 1795 insertions(+), 278 deletions(-) create mode 100644 tests/unit/commands_dispatch_spec.lua create mode 100644 tests/unit/commands_handlers_spec.lua create mode 100644 tests/unit/commands_parse_spec.lua create mode 100644 tests/unit/commands_router_spec.lua create mode 100644 tests/unit/entry_dependency_guard_spec.lua create mode 100644 tests/unit/registry_loader_spec.lua diff --git a/lua/opencode/commands/handlers/agent.lua b/lua/opencode/commands/handlers/agent.lua index 1450b0ba..63a2072a 100644 --- a/lua/opencode/commands/handlers/agent.lua +++ b/lua/opencode/commands/handlers/agent.lua @@ -1,5 +1,6 @@ local core = require('opencode.core') local config_file = require('opencode.config_file') +---@type OpencodeState local state = require('opencode.state') local util = require('opencode.util') local Promise = require('opencode.promise') diff --git a/lua/opencode/commands/handlers/diff.lua b/lua/opencode/commands/handlers/diff.lua index a65ec212..8e7227ff 100644 --- a/lua/opencode/commands/handlers/diff.lua +++ b/lua/opencode/commands/handlers/diff.lua @@ -1,6 +1,7 @@ local core = require('opencode.core') local git_review = require('opencode.git_review') local session_store = require('opencode.session') +---@type OpencodeState local state = require('opencode.state') local M = { diff --git a/lua/opencode/commands/handlers/permission.lua b/lua/opencode/commands/handlers/permission.lua index 5acd5ca7..f6d75ce5 100644 --- a/lua/opencode/commands/handlers/permission.lua +++ b/lua/opencode/commands/handlers/permission.lua @@ -1,3 +1,4 @@ +---@type OpencodeState local state = require('opencode.state') local M = { diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index e0e9feaf..d28a6a18 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -3,6 +3,13 @@ local M = { handlers = {}, } +local core = require('opencode.core') +---@type OpencodeState +local state = require('opencode.state') +local session_store = require('opencode.session') +local Promise = require('opencode.promise') +local window_actions = require('opencode.commands.handlers.window').actions + ---@param message string local function invalid_arguments(message) error({ @@ -11,57 +18,37 @@ local function invalid_arguments(message) }, 0) end -local function core() - return require('opencode.core') -end - -local function state() - return require('opencode.state') -end - -local function session_store() - return require('opencode.session') -end - -local function Promise() - return require('opencode.promise') -end - -local function window_actions() - return require('opencode.commands.handlers.window').actions -end - function M.actions.open_input_new_session() - return core().open({ new_session = true, focus = 'input', start_insert = true }) + return core.open({ new_session = true, focus = 'input', start_insert = true }) end ---@param title string function M.actions.open_input_new_session_with_title(title) - return Promise().async(function(session_title) - local new_session = core().create_new_session(session_title):await() + return Promise.async(function(session_title) + local new_session = core.create_new_session(session_title):await() if not new_session then vim.notify('Failed to create new session', vim.log.levels.ERROR) return end - state().session.set_active(new_session) - return window_actions().open_input() + state.session.set_active(new_session) + return window_actions.open_input() end)(title) end ---@param parent_id? string function M.actions.select_session(parent_id) - core().select_session(parent_id) + core.select_session(parent_id) end function M.actions.select_child_session() - local active = state().active_session - core().select_session(active and active.id or nil) + local active = state.active_session + core.select_session(active and active.id or nil) end ---@param current_session? Session function M.actions.compact_session(current_session) - local state_obj = state() + local state_obj = state current_session = current_session or state_obj.active_session if not current_session then vim.notify('No active session to compact', vim.log.levels.WARN) @@ -98,7 +85,7 @@ function M.actions.compact_session(current_session) end function M.actions.share() - local state_obj = state() + local state_obj = state if not state_obj.active_session then vim.notify('No active session to share', vim.log.levels.WARN) return @@ -124,7 +111,7 @@ function M.actions.share() end function M.actions.unshare() - local state_obj = state() + local state_obj = state if not state_obj.active_session then vim.notify('No active session to unshare', vim.log.levels.WARN) return @@ -145,10 +132,10 @@ function M.actions.unshare() end function M.actions.initialize() - return Promise().async(function() + return Promise.async(function() local id = require('opencode.id') - local state_obj = state() - local core_obj = core() + local state_obj = state + local core_obj = core local new_session = core_obj.create_new_session('AGENTS.md Initialization'):await() if not new_session then @@ -168,7 +155,7 @@ function M.actions.initialize() end state_obj.session.set_active(new_session) - window_actions().open_input() + window_actions.open_input() state_obj.api_client:init_session(state_obj.active_session.id, { providerID = providerId, modelID = modelId, @@ -180,9 +167,9 @@ end ---@param current_session? Session ---@param new_title? string function M.actions.rename_session(current_session, new_title) - return Promise().async(function(session_obj, requested_title) - local promise = Promise().new() - local state_obj = state() + return Promise.async(function(session_obj, requested_title) + local promise = Promise.new() + local state_obj = state session_obj = session_obj or (state_obj.active_session and vim.deepcopy(state_obj.active_session) or nil) --[[@as Session]] if not session_obj then vim.notify('No active session to rename', vim.log.levels.WARN) @@ -198,10 +185,10 @@ function M.actions.rename_session(current_session, new_title) vim.notify('Failed to rename session: ' .. vim.inspect(err), vim.log.levels.ERROR) end) end) - :and_then(Promise().async(function() + :and_then(Promise.async(function() session_obj.title = title if state_obj.active_session and state_obj.active_session.id == session_obj.id then - local persisted_session = session_store().get_by_id(session_obj.id):await() + local persisted_session = session_store.get_by_id(session_obj.id):await() if persisted_session then persisted_session.title = title state_obj.session.set_active(vim.deepcopy(persisted_session)) diff --git a/lua/opencode/commands/handlers/window.lua b/lua/opencode/commands/handlers/window.lua index 47f8084f..cfb7153d 100644 --- a/lua/opencode/commands/handlers/window.lua +++ b/lua/opencode/commands/handlers/window.lua @@ -1,4 +1,5 @@ local core = require('opencode.core') +---@type OpencodeState local state = require('opencode.state') local ui = require('opencode.ui.ui') local config = require('opencode.config') diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index 01f31cd6..a659b0e0 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -1,6 +1,7 @@ local core = require('opencode.core') local util = require('opencode.util') local config_file = require('opencode.config_file') +---@type OpencodeState local state = require('opencode.state') local quick_chat = require('opencode.quick_chat') local history = require('opencode.history') diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index f7a0f318..970d1257 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -6,7 +6,7 @@ local model = require('opencode.state.model') local renderer = require('opencode.state.renderer') local context = require('opencode.state.context') ----@class OpencodeStateModule +---@class OpencodeState : OpencodeStateData ---@field store OpencodeStateStore ---@field session OpencodeSessionStateMutations ---@field jobs OpencodeJobStateMutations @@ -14,8 +14,9 @@ local context = require('opencode.state.context') ---@field model OpencodeModelStateMutations ---@field renderer OpencodeRendererStateMutations ---@field context OpencodeContextStateMutations - ----@alias OpencodeState OpencodeStateModule & OpencodeStateData +---@field active_session Session|nil +---@field current_model string|nil +---@field api_client OpencodeApiClient|nil ---@type OpencodeState local M = { diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 45d14afe..369823e7 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -42,9 +42,206 @@ ---@class OpencodeUICommand ---@field desc string ----@field completions? string[]|function +---@field handler_id string +---@field completions? string[] +---@field nested_subcommand? OpencodeNestedSubcommandValidation +---@field completion_provider_id? string ---@field sub_completions? string[] ----@field fn function +---@field nargs? string|integer +---@field range? boolean +---@field complete? boolean + +---@class OpencodeNestedSubcommandValidation +---@field allow_empty boolean + +---@alias OpencodeCommandApiMethod +---| 'open_input' +---| 'open_output' +---| 'open_input_new_session' +---| 'select_history' +---| 'select_session' +---| 'select_child_session' +---| 'compact_session' +---| 'share' +---| 'unshare' +---| 'initialize' +---| 'rename_session' +---| 'configure_provider' +---| 'configure_variant' +---| 'select_agent' +---| 'agent_plan' +---| 'agent_build' +---| 'swap_position' +---| 'run_new_session' +---| 'set_review_breakpoint' +---| 'diff_open' +---| 'diff_next' +---| 'diff_prev' +---| 'diff_close' +---| 'diff_revert_all_last_prompt' +---| 'diff_revert_this_last_prompt' +---| 'open' +---| 'close' +---| 'hide' +---| 'cancel' +---| 'toggle' +---| 'toggle_focus' +---| 'toggle_pane' +---| 'toggle_zoom' +---| 'toggle_input' +---| 'quick_chat' +---| 'swap' +---| 'review' +---| 'session' +---| 'undo' +---| 'redo' +---| 'diff' +---| 'revert' +---| 'restore' +---| 'breakpoint' +---| 'agent' +---| 'models' +---| 'variant' +---| 'run' +---| 'run_new' +---| 'command' +---| 'help' +---| 'history' +---| 'mcp' +---| 'commands_list' +---| 'permission' +---| 'timeline' +---| 'toggle_tool_output' +---| 'toggle_reasoning_output' +---| 'paste_image' +---| 'references' +---| 'add_visual_selection' + +---@class OpencodeCommandApi +---@field add_visual_selection fun(opts?: {open_input?: boolean}|string[], range?: OpencodeSelectionRange): any +---@field add_visual_selection_inline fun(opts?: {open_input?: boolean}|string[], range?: OpencodeSelectionRange): any +---@field agent_build fun() +---@field agent_plan fun() +---@field cancel fun() +---@field close fun() +---@field commands_list fun() +---@field compact_session fun(current_session?: Session) +---@field configure_provider fun() +---@field configure_variant fun() +---@field context_items fun() +---@field current_model fun(): any +---@field cycle_variant fun() +---@field debug_message fun() +---@field debug_output fun() +---@field debug_session fun() +---@field diff_close fun() +---@field diff_next fun() +---@field diff_open fun(from_snapshot_id?: string, to_snapshot_id?: string|number) +---@field diff_prev fun() +---@field diff_restore_snapshot_all fun(restore_point_id?: string) +---@field diff_restore_snapshot_file fun(restore_point_id?: string) +---@field diff_revert_all fun(from_snapshot_id?: string) +---@field diff_revert_all_last_prompt fun() +---@field diff_revert_selected_file fun(from_snapshot_id?: string, to_snapshot_id?: string) +---@field diff_revert_this fun(snapshot_id?: string) +---@field diff_revert_this_last_prompt fun() +---@field focus_input fun() +---@field fork_session fun(message_id?: string) +---@field get_window_state fun(): {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} +---@field help fun() +---@field hide fun() +---@field initialize fun() +---@field mcp fun() +---@field mention fun() +---@field mention_file fun() +---@field next_history fun() +---@field next_message fun() +---@field next_prompt_history fun() +---@field open_input fun(): any +---@field open_input_new_session fun(): any +---@field open_input_new_session_with_title fun(title: string): any +---@field open_output fun(): any +---@field paste_image fun() +---@field permission_accept fun(permission?: OpencodePermission) +---@field permission_accept_all fun(permission?: OpencodePermission) +---@field permission_deny fun(permission?: OpencodePermission) +---@field prev_history fun() +---@field prev_message fun() +---@field prev_prompt_history fun() +---@field question_answer fun() +---@field question_other fun() +---@field quick_chat fun(message?: string[]|string, range?: OpencodeSelectionRange): any +---@field redo fun() +---@field references fun() +---@field rename_session fun(current_session?: Session, new_title?: string): any +---@field respond_to_permission fun(answer?: 'once'|'always'|'reject', permission?: OpencodePermission) +---@field review fun(args: string[]): any +---@field run fun(prompt: string, opts?: SendMessageOpts): any +---@field run_new_session fun(prompt: string, opts?: SendMessageOpts): any +---@field run_user_command fun(name: string, args?: string[]): any +---@field select_agent fun(): any +---@field select_child_session fun() +---@field select_history fun() +---@field select_session fun(parent_id?: string) +---@field set_review_breakpoint fun() +---@field share fun() +---@field slash_commands fun() +---@field submit_input_prompt fun(): any +---@field swap_position fun() +---@field switch_mode fun(): any +---@field timeline fun() +---@field toggle fun(new_session?: boolean): any +---@field toggle_focus fun(new_session?: boolean) +---@field toggle_input fun() +---@field toggle_pane fun() +---@field toggle_reasoning_output fun() +---@field toggle_tool_output fun() +---@field toggle_zoom fun() +---@field unshare fun() +---@field undo fun(messageId?: string) +---@field with_header fun(lines: string[], show_welcome?: boolean): string[] + +---@alias OpencodeCommandHandler fun(api: OpencodeCommandApi, args: string[], range?: OpencodeSelectionRange): any +---@alias OpencodeCommandHandlerMap table + +---@class OpencodeCommandIntentRaw +---@field args string +---@field argv string[] +---@field subcommand string + +---@class OpencodeCommandIntent +---@field handler_id string +---@field args string[] +---@field range OpencodeSelectionRange|nil +---@field raw OpencodeCommandIntentRaw + +---@class OpencodeCommandParseError +---@field code 'unknown_subcommand'|'missing_handler'|'invalid_subcommand' +---@field message string +---@field subcommand string + +---@class OpencodeCommandParseResult +---@field ok boolean +---@field intent? OpencodeCommandIntent +---@field error? OpencodeCommandParseError + +---@class OpencodeCommandRouteOpts +---@field args? string +---@field range? integer +---@field line1? integer +---@field line2? integer + +---@class OpencodeCommandDispatchError +---@field code 'unknown_subcommand'|'missing_handler'|'invalid_subcommand'|'invalid_arguments'|'unknown_handler'|'handler_exception'|'invalid_command' +---@field message string +---@field subcommand? string +---@field handler_id? string + +---@class OpencodeCommandDispatchResult +---@field ok boolean +---@field intent? OpencodeCommandIntent +---@field result? any +---@field error? OpencodeCommandDispatchError ---@class SessionRevertInfo ---@field messageID string @@ -208,11 +405,36 @@ ---@field show_ids boolean ---@field quick_chat {keep_session: boolean, set_active_session: boolean} +---@alias OpencodeCommandLifecycleStage 'before'|'after'|'error'|'finally' +---@alias OpencodeCommandDispatchHook fun(ctx: OpencodeCommandDispatchContext): OpencodeCommandDispatchContext|nil + ---@class OpencodeHooks ---@field on_file_edited? fun(file: string): nil ---@field on_session_loaded? fun(session: Session): nil ---@field on_done_thinking? fun(session: Session): nil ---@field on_permission_requested? fun(session: Session): nil +---@field on_command_before? OpencodeCommandDispatchHook +---@field on_command_after? OpencodeCommandDispatchHook +---@field on_command_error? OpencodeCommandDispatchHook +---@field on_command_finally? OpencodeCommandDispatchHook + +---@class OpencodeCommandDispatchContext +---@field parsed OpencodeCommandParseResult +---@field intent OpencodeCommandIntent|nil +---@field args string[]|nil +---@field range OpencodeSelectionRange|nil +---@field result? any +---@field error OpencodeCommandDispatchError|nil + +---@class OpencodeCommandLifecycleHookSpec +---@field before? OpencodeCommandDispatchHook +---@field after? OpencodeCommandDispatchHook +---@field error? OpencodeCommandDispatchHook +---@field finally? OpencodeCommandDispatchHook +---@field on_command_before? OpencodeCommandDispatchHook +---@field on_command_after? OpencodeCommandDispatchHook +---@field on_command_error? OpencodeCommandDispatchHook +---@field on_command_finally? OpencodeCommandDispatchHook ---@class OpencodeProviders ---@field [string] string[] @@ -233,6 +455,42 @@ ---@field level 'debug' | 'info' | 'warn' | 'error' ---@field outfile string|nil +---@alias OpencodeExtensionConflictPolicy 'error'|'override'|'skip' + +---@class OpencodeExtensionsConfig +---@field builtin table +---@field user table +---@field conflict_policy OpencodeExtensionConflictPolicy + +---@class OpencodeSlashCommandSpec +---@field desc string|nil +---@field args boolean|nil +---@field api_method OpencodeCommandApiMethod|nil +---@field fn fun(args:string[]|nil):nil|Promise|nil + +---@class OpencodeExtensionSpec +---@field commands? table +---@field slash_commands? table +---@field handlers? OpencodeCommandHandlerMap +---@field hooks? table +---@field context_sources? table +---@field completion_sources? table +---@field formatters? table +---@field usecases? OpencodeCommandHandlerMap # @deprecated compatibility-only; use handlers + +---@alias OpencodeRegistryCapabilityKey +---| 'commands' +---| 'slash_commands' +---| 'handlers' +---| 'hooks' +---| 'context_sources' +---| 'completion_sources' +---| 'formatters' + +---@class OpencodeExtensionRegisterOpts +---@field conflict_policy? OpencodeExtensionConflictPolicy +---@field source? string + ---@class OpencodeConfig ---@field preferred_picker 'telescope' | 'fzf' | 'mini.pick' | 'snacks' | 'select' | nil ---@field default_global_keymaps boolean @@ -248,6 +506,7 @@ ---@field debug OpencodeDebugConfig ---@field prompt_guard? fun(mentioned_files: string[]): boolean ---@field hooks OpencodeHooks +---@field extensions OpencodeExtensionsConfig ---@field quick_chat OpencodeQuickChatConfig ---@class MessagePartState @@ -473,6 +732,42 @@ ---@field get_trigger_character? fun(): string|nil Optional function returning the trigger character for this source ---@field custom_kind? integer Custom LSP CompletionItemKind registered for this source +---@alias OpencodeContextResolveEffectType +---| 'toggle_context' +---| 'set_context' +---| 'remove_file' +---| 'remove_subagent' +---| 'remove_selection' +---| 'remove_input_mention' +---| 'remove_completion_text' + +---@class OpencodeContextResolveEffect +---@field type OpencodeContextResolveEffectType +---@field context_key? OpencodeToggleableContextKey +---@field enabled? boolean +---@field file_path? string +---@field subagent? string +---@field selection? OpencodeContextSelection + +---@class OpencodeContextResolveResult +---@field effects OpencodeContextResolveEffect[] + +---@class OpencodeContextCandidate +---@field label string +---@field detail? string +---@field documentation? string +---@field insert_text? string +---@field kind_icon? string +---@field kind_hl? string +---@field priority? number +---@field meta? table + +---@class OpencodeContextSource +---@field priority? number +---@field trigger? fun(context: CompletionContext): boolean|Promise +---@field complete fun(context: CompletionContext): OpencodeContextCandidate[]|Promise +---@field resolve fun(candidate: OpencodeContextCandidate, item?: CompletionItem): OpencodeContextResolveResult|Promise + ---Extended LSP completion item with opencode-specific rendering fields ---@class OpencodeLspItem : lsp.CompletionItem ---@field kind lsp.CompletionItemKind diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index a6a66963..467ae56e 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -1,4 +1,8 @@ local api = require('opencode.api') +local commands = require('opencode.commands') +local command_registry = require('opencode.registry') +local command_handlers = require('opencode.commands.handlers') +local slash = require('opencode.commands.slash') local core = require('opencode.core') local ui = require('opencode.ui.ui') local state = require('opencode.state') @@ -32,44 +36,92 @@ describe('opencode.api', function() -- luassert.stub automatically restores originals after each test end) - describe('commands table', function() + describe('command registry', function() it('contains the expected commands with proper structure', function() - local expected_commands = { - 'open', - 'close', - 'cancel', - 'toggle', - 'toggle_focus', - 'toggle_pane', - 'session', - 'swap', - 'undo', - 'redo', - 'diff', - 'revert', - 'restore', - 'breakpoint', - 'agent', - 'models', - 'run', - 'run_new', - 'help', - 'mcp', - 'permission', - } - - for _, cmd_name in ipairs(expected_commands) do - local cmd = api.commands[cmd_name] - assert.truthy(cmd, 'Command ' .. cmd_name .. ' should exist') + local defs = command_registry.get_commands() + local required_commands = { 'open', 'session', 'run', 'toggle_zoom' } + + for cmd_name, cmd in pairs(defs) do + assert.truthy(cmd_name, 'Command name should exist') assert.truthy(cmd.desc, 'Command should have a description') - assert.is_function(cmd.fn, 'Command should have a function') + assert.is_string(cmd.handler_id, 'Command should have a handler id') + assert.is_nil(cmd.fn, 'Registry should not carry command execution functions') + assert.not_equal('function', type(cmd.completions), 'Registry should not carry runtime completion functions') + end + + for _, cmd_name in ipairs(required_commands) do + assert.truthy(defs[cmd_name], 'Command ' .. cmd_name .. ' should exist') + end + + assert.equal('user_commands', defs.command.completion_provider_id) + end) + + it('contains the expected builtin slash definitions', function() + local defs = command_registry.get_slash_commands() + + assert.truthy(defs['/help']) + assert.equal('help', defs['/help'].api_method) + assert.equal('Show help message', defs['/help'].desc) + assert.is_nil(defs['/help'].fn) + end) + + it('resolves every registry handler_id in handlers', function() + local defs = command_registry.get_commands() + + for command_name, def in pairs(defs) do + local handler = command_handlers.get(def.handler_id) + assert.is_function(handler, 'Missing handler for command: ' .. command_name) + end + end) + + it('has no handlers outside registry handler_id set', function() + local defs = command_registry.get_commands() + local handler_ids = {} + + for _, def in pairs(defs) do + handler_ids[def.handler_id] = true + end + + for _, handler_id in ipairs(command_handlers.ids()) do + assert.truthy(handler_ids[handler_id], 'Handler not declared in registry: ' .. handler_id) end end) end) + describe('command routing', function() + it('routes empty command args through handlers.execute using toggle handler', function() + local execute_stub = stub(command_handlers, 'execute').returns(true, 'ok') + + local result = commands.route_command({ args = '', range = 0 }) + + assert.stub(execute_stub).was_called(1) + local call_args = execute_stub.calls[1].refs + assert.equal('toggle', call_args[1]) + assert.same({}, call_args[3]) + assert.same('ok', result) + execute_stub:revert() + end) + + it('reports invalid nested subcommand before handler execution', function() + local execute_stub = stub(command_handlers, 'execute') + local notify_stub = stub(vim, 'notify') + + local result = commands.route_command({ args = 'agent unknown', range = 0 }) + + assert.is_nil(result) + assert.stub(execute_stub).was_not_called() + assert + .stub(notify_stub) + .was_called_with('Invalid agent subcommand. Use: plan, build, select', vim.log.levels.ERROR) + + execute_stub:revert() + notify_stub:revert() + end) + end) + describe('setup', function() it('registers the main Opencode command', function() - api.setup() + commands.setup() assert.equal(1, #created_commands) assert.equal('Opencode', created_commands[1].name) @@ -77,6 +129,44 @@ describe('opencode.api', function() end) end) + describe('public boundary', function() + it('does not expose command layer APIs via opencode.api', function() + assert.is_nil(api.setup) + assert.is_nil(api.get_slash_commands) + assert.is_nil(api.commands) + end) + end) + + describe('actions consolidation', function() + it('keeps display/permission/history/session APIs callable from api', function() + assert.is_nil(package.loaded['opencode.actions']) + + assert.is_function(api.close) + assert.is_function(api.hide) + assert.is_function(api.with_header) + assert.is_function(api.help) + assert.is_function(api.commands_list) + assert.is_function(api.submit_input_prompt) + assert.is_function(api.toggle_tool_output) + assert.is_function(api.toggle_reasoning_output) + + assert.is_function(api.select_history) + assert.is_function(api.prev_history) + assert.is_function(api.next_history) + assert.is_function(api.prev_prompt_history) + assert.is_function(api.next_prompt_history) + + assert.is_function(api.respond_to_permission) + assert.is_function(api.permission_accept) + assert.is_function(api.permission_accept_all) + assert.is_function(api.permission_deny) + assert.is_function(api.question_answer) + assert.is_function(api.question_other) + + assert.is_function(api.open_input_new_session_with_title) + end) + end) + describe('Lua API', function() it('provides callable functions that match commands', function() -- All core/ui methods are stubbed in before_each; no need for local spies or wrappers @@ -87,6 +177,19 @@ describe('opencode.api', function() assert.stub(core.open).was_called() assert.stub(core.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) + local create_new_session_stub = stub(core, 'create_new_session').invokes(function() + return Promise.new():resolve({ id = 'session-1' }) + end) + local set_active_stub = stub(state.session, 'set_active') + + assert.is_function(api.open_input_new_session_with_title, 'Should export open_input_new_session_with_title') + api.open_input_new_session_with_title('My Session'):wait() + assert.stub(create_new_session_stub).was_called_with('My Session') + assert.stub(set_active_stub).was_called_with({ id = 'session-1' }) + assert.stub(core.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) + create_new_session_stub:revert() + set_active_stub:revert() + -- Test run function assert.is_function(api.run, 'Should export run') api.run('test prompt'):wait() @@ -108,45 +211,15 @@ describe('opencode.api', function() end) describe('run command argument parsing', function() - it('parses agent prefix and passes to send_message', function() - api.commands.run.fn({ 'agent=plan', 'analyze', 'this', 'code' }):wait() - assert.stub(core.send_message).was_called() - assert.stub(core.send_message).was_called_with('analyze this code', { - new_session = false, - focus = 'output', - agent = 'plan', - }) - end) - - it('parses model prefix and passes to send_message', function() - api.commands.run.fn({ 'model=openai/gpt-4', 'test', 'prompt' }):wait() - assert.stub(core.send_message).was_called() - assert.stub(core.send_message).was_called_with('test prompt', { - new_session = false, - focus = 'output', - model = 'openai/gpt-4', - }) - end) - - it('parses context prefix and passes to send_message', function() - api.commands.run.fn({ 'context=current_file.enabled=false', 'test' }):wait() - assert.stub(core.send_message).was_called() - assert.stub(core.send_message).was_called_with('test', { - new_session = false, - focus = 'output', - context = { current_file = { enabled = false } }, - }) - 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', - }) + command_handlers + .get('run')(api, { + 'agent=plan', + 'model=openai/gpt-4', + 'context=current_file.enabled=false', + 'analyze', + 'code', + }) :wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('analyze code', { @@ -159,7 +232,7 @@ describe('opencode.api', function() end) it('works with run_new command', function() - api.commands.run_new.fn({ 'agent=plan', 'model=openai/gpt-4', 'new', 'session', 'prompt' }):wait() + command_handlers.get('run_new')(api, { 'agent=plan', 'model=openai/gpt-4', 'new', 'session', 'prompt' }):wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('new session prompt', { new_session = true, @@ -170,10 +243,15 @@ describe('opencode.api', function() end) it('requires a prompt after prefixes', function() - local notify_stub = stub(vim, 'notify') - api.commands.run.fn({ 'agent=plan' }) - assert.stub(notify_stub).was_called_with('Prompt required', vim.log.levels.ERROR) - notify_stub:revert() + local ok, result, err = command_handlers.execute('run', api, { 'agent=plan' }, nil) + + assert.is_true(ok) + assert.is_nil(result) + assert.same({ + code = 'invalid_arguments', + message = 'Prompt required', + handler_id = 'run', + }, err) end) it('Lua API accepts opts directly without parsing', function() @@ -204,11 +282,10 @@ describe('opencode.api', function() end stub(ui, 'render_lines') - stub(api, 'open_input') - api.commands_list() + api.commands_list():wait() - assert.stub(api.open_input).was_called() + assert.stub(core.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) assert.stub(ui.render_lines).was_called() local render_args = ui.render_lines.calls[1].refs[1] @@ -238,7 +315,7 @@ describe('opencode.api', function() local notify_stub = stub(vim, 'notify') - api.commands_list() + api.commands_list():wait() assert .stub(notify_stub) @@ -250,7 +327,21 @@ describe('opencode.api', function() end) describe('command autocomplete', function() - it('provides user command names for completion', function() + it('resolves provider completions through registry', function() + local completion_providers = require('opencode.commands.completion_providers') + local get_stub = stub(completion_providers, 'get').returns(function() + return { 'build', 'deploy' } + end) + + local completions = commands.complete_command('b', 'Opencode command b', 18) + + assert.stub(get_stub).was_called_with('user_commands') + assert.same({ 'build' }, completions) + + get_stub:revert() + end) + + it('provides sorted user command names for completion', function() local config_file = require('opencode.config_file') local original_get_user_commands = config_file.get_user_commands @@ -264,11 +355,9 @@ describe('opencode.api', function() return p end - local completions = api.commands.command.completions() + local completions = commands.complete_command('', 'Opencode command ', 17) - assert.truthy(vim.tbl_contains(completions, 'build')) - assert.truthy(vim.tbl_contains(completions, 'test')) - assert.truthy(vim.tbl_contains(completions, 'deploy')) + assert.same({ 'build', 'deploy', 'test' }, completions) config_file.get_user_commands = original_get_user_commands end) @@ -283,72 +372,71 @@ describe('opencode.api', function() return p end - local completions = api.commands.command.completions() + local completions = commands.complete_command('', 'Opencode command ', 17) 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() - local p = Promise.new() - p:resolve({ - ['build'] = { description = 'Build the project' }, - ['test'] = { description = 'Run tests' }, - }) - return p - end - - local results = api.complete_command('b', 'Opencode command b', 18) + it('returns empty array for invalid provider id', function() + local get_commands_stub = stub(command_registry, 'get_commands').returns({ + broken = { + desc = 'Broken completion provider command', + handler_id = 'help', + completion_provider_id = 'missing_provider', + }, + }) - assert.truthy(vim.tbl_contains(results, 'build')) - assert.falsy(vim.tbl_contains(results, 'test')) + assert.has_no.errors(function() + local completions = commands.complete_command('', 'Opencode broken ', 16) + assert.same({}, completions) + end) - config_file.get_user_commands = original_get_user_commands + get_commands_stub:revert() end) end) describe('slash commands with user commands', function() - it('includes user commands in get_slash_commands', function() + it('returns invalid_command error for unroutable slash api_method', function() local config_file = require('opencode.config_file') local original_get_user_commands = config_file.get_user_commands + local notify_stub = stub(vim, 'notify') + local get_slash_stub = stub(command_registry, 'get_slash_commands').returns({ + ['/broken'] = { + api_method = 'unknown_method', + desc = 'Broken slash command', + }, + }) config_file.get_user_commands = function() - local p = Promise.new() - p:resolve({ - ['build'] = { description = 'Build the project' }, - ['test'] = { description = 'Run tests', template = 'Run tests with $ARGUMENTS' }, - }) - return p + return Promise.new():resolve({}) end - local slash_commands = api.get_slash_commands():wait() - - local build_found = false - local test_found = false + local slash_commands = slash.get_commands():wait() + local broken 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.truthy(cmd.args) + if cmd.slash_cmd == '/broken' then + broken = cmd + break end end - assert.truthy(build_found, 'Should include /build command') - assert.truthy(test_found, 'Should include /test command') + assert.is_not_nil(broken) + + local err = broken.fn() + + assert.same({ + code = 'invalid_command', + message = 'Unknown command method: unknown_method', + subcommand = 'unknown_method', + }, err) + assert.stub(notify_stub).was_called_with('Unknown command method: unknown_method', vim.log.levels.ERROR) config_file.get_user_commands = original_get_user_commands + get_slash_stub:revert() + notify_stub:revert() end) describe('user command model/agent selection', function() @@ -365,7 +453,6 @@ describe('opencode.api', function() config_file.get_user_commands = function() local p = Promise.new() p:resolve({ - ['test-no-model'] = { description = 'Run tests', template = 'Run tests with $ARGUMENTS' }, ['test-with-model'] = { description = 'Run tests', template = 'Run tests with $ARGUMENTS', @@ -392,32 +479,18 @@ describe('opencode.api', function() end, }) - local slash_commands = api.get_slash_commands():wait() + local slash_commands = slash.get_commands():wait() - local test_no_model_cmd = nil - local test_with_model_cmd = nil + local test_with_model_cmd for _, cmd in ipairs(slash_commands) do - if cmd.slash_cmd == '/test-no-model' then - test_no_model_cmd = cmd - elseif cmd.slash_cmd == '/test-with-model' then + if cmd.slash_cmd == '/test-with-model' then test_with_model_cmd = cmd end end - assert.truthy(test_no_model_cmd, 'Should find /test-no-model command') assert.truthy(test_with_model_cmd, 'Should find /test-with-model command') - test_no_model_cmd.fn():wait() - assert.equal(1, #send_command_calls) - assert.equal('test-session', send_command_calls[1].session_id) - assert.equal('test-no-model', send_command_calls[1].command_data.command) - assert.equal('', send_command_calls[1].command_data.arguments) - assert.equal(nil, send_command_calls[1].command_data.model) - assert.equal(nil, send_command_calls[1].command_data.agent) - - send_command_calls = {} - test_with_model_cmd.fn():wait() assert.equal(1, #send_command_calls) assert.equal('test-session', send_command_calls[1].session_id) @@ -444,7 +517,7 @@ describe('opencode.api', function() return p end - local slash_commands = api.get_slash_commands():wait() + local slash_commands = slash.get_commands():wait() local custom_found = false for _, cmd in ipairs(slash_commands) do @@ -471,7 +544,7 @@ describe('opencode.api', function() return p end - local slash_commands = api.get_slash_commands():wait() + local slash_commands = slash.get_commands():wait() local help_found = false local build_found = false @@ -534,18 +607,11 @@ describe('opencode.api', function() end) it('is available in the commands table', function() - local cmd = api.commands['toggle_zoom'] + local cmd = command_registry.get_commands()['toggle_zoom'] assert.truthy(cmd, 'toggle_zoom command should exist') assert.equal('Toggle window zoom', cmd.desc) - assert.is_function(cmd.fn) - end) - - it('routes through command interface', function() - stub(ui, 'toggle_zoom') - - api.commands.toggle_zoom.fn({}) - - assert.stub(ui.toggle_zoom).was_called() + assert.equal('toggle_zoom', cmd.handler_id) + assert.is_nil(cmd.fn) end) end) end) diff --git a/tests/unit/commands_dispatch_spec.lua b/tests/unit/commands_dispatch_spec.lua new file mode 100644 index 00000000..d4e89c2c --- /dev/null +++ b/tests/unit/commands_dispatch_spec.lua @@ -0,0 +1,352 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local command_dispatch = require('opencode.commands.dispatch') +local command_parse = require('opencode.commands.parse') +local command_registry = require('opencode.registry') +local command_handlers = require('opencode.commands.handlers') +local config = require('opencode.config') +local state = require('opencode.state') + +describe('opencode.commands.dispatch', function() + local original_hooks + local original_event_manager + + before_each(function() + original_hooks = config.hooks + original_event_manager = state.event_manager + config.hooks = vim.deepcopy(config.hooks or {}) + state.jobs.set_event_manager(nil) + end) + + after_each(function() + config.hooks = original_hooks + state.jobs.set_event_manager(original_event_manager) + end) + + it('normalizes parse errors as fail result', function() + local parsed = command_parse.command({ args = 'not_real', range = 0 }, command_registry.get_commands()) + + local result = command_dispatch.command(parsed, {}) + + assert.is_false(result.ok) + assert.same({ + code = 'unknown_subcommand', + message = 'Unknown subcommand: not_real', + subcommand = 'not_real', + }, result.error) + assert.is_nil(result.intent) + end) + + it('normalizes missing handler execution as fail result', function() + local execute_stub = stub(command_handlers, 'execute').returns(false, nil) + local parsed = { + ok = true, + intent = { + handler_id = 'missing_handler', + args = { 'a' }, + range = nil, + raw = { + args = 'missing_handler a', + argv = { 'missing_handler', 'a' }, + subcommand = 'missing_handler', + }, + }, + } + + local result = command_dispatch.command(parsed, {}) + + assert.is_false(result.ok) + assert.equal('unknown_handler', result.error.code) + assert.equal('Unknown command handler: missing_handler', result.error.message) + assert.equal('missing_handler', result.error.handler_id) + assert.same(parsed.intent, result.intent) + execute_stub:revert() + end) + + it('normalizes handler runtime exceptions as fail result', function() + local execute_stub = stub(command_handlers, 'execute').returns(true, nil, { + code = 'handler_exception', + message = 'Command handler failed: toggle', + handler_id = 'toggle', + }) + local parsed = command_parse.command({ args = '', range = 0 }, command_registry.get_commands()) + + local result = command_dispatch.command(parsed, {}) + + assert.is_false(result.ok) + assert.same({ + code = 'handler_exception', + message = 'Command handler failed: toggle', + handler_id = 'toggle', + }, result.error) + assert.same(parsed.intent, result.intent) + execute_stub:revert() + end) + + it('normalizes successful execution with intent and handler result', function() + local execute_stub = stub(command_handlers, 'execute').returns(true, 'done') + local parsed = command_parse.command({ args = '', range = 0 }, command_registry.get_commands()) + + local result = command_dispatch.command(parsed, {}) + + assert.is_true(result.ok) + assert.equal('done', result.result) + assert.equal('toggle', result.intent.handler_id) + assert.same({}, result.intent.args) + local call_args = execute_stub.calls[1].refs + assert.equal('toggle', call_args[1]) + assert.same({}, call_args[3]) + assert.is_nil(call_args[4]) + execute_stub:revert() + end) + + it('normalizes handler argument errors as fail result', function() + local parsed = command_parse.command({ args = 'revert all', range = 0 }, command_registry.get_commands()) + + local result = command_dispatch.command(parsed, {}) + + assert.is_false(result.ok) + assert.same({ + code = 'invalid_arguments', + message = 'Invalid revert target. Use: prompt, session, or ', + handler_id = 'revert', + }, result.error) + assert.equal('revert', result.intent.handler_id) + end) + + it('runs before -> handler -> after lifecycle in order', function() + local events = {} + local execute_stub = stub(command_handlers, 'execute').invokes(function(...) + table.insert(events, 'handler') + return true, 'ok', nil + end) + + config.hooks.on_command_before = function(_) + table.insert(events, 'before') + end + config.hooks.on_command_after = function(_) + table.insert(events, 'after') + end + config.hooks.on_command_finally = function(_) + table.insert(events, 'finally') + end + + local emitted = {} + state.jobs.set_event_manager({ + emit = function(_, event_name, _) + table.insert(emitted, event_name) + end, + }) + + local parsed = command_parse.command({ args = '', range = 0 }, command_registry.get_commands()) + local result = command_dispatch.command(parsed, {}) + + assert.is_true(result.ok) + assert.same({ 'before', 'handler', 'after', 'finally' }, events) + assert.same({ + 'custom.command.before', + 'custom.command.after', + 'custom.command.finally', + }, emitted) + + execute_stub:revert() + end) + + it('triggers error and finally when handler returns error', function() + local stages = {} + local execute_stub = stub(command_handlers, 'execute').returns(true, nil, { + code = 'handler_exception', + message = 'Command handler failed: toggle', + handler_id = 'toggle', + }) + + config.hooks.on_command_error = function(_) + table.insert(stages, 'error') + end + config.hooks.on_command_finally = function(_) + table.insert(stages, 'finally') + end + + local emitted = {} + state.jobs.set_event_manager({ + emit = function(_, event_name, _) + table.insert(emitted, event_name) + end, + }) + + local parsed = command_parse.command({ args = '', range = 0 }, command_registry.get_commands()) + local result = command_dispatch.command(parsed, {}) + + assert.is_false(result.ok) + assert.same({ 'error', 'finally' }, stages) + assert.same({ + 'custom.command.before', + 'custom.command.error', + 'custom.command.finally', + }, emitted) + + execute_stub:revert() + end) + + it('isolates hook errors from main dispatch flow', function() + local execute_stub = stub(command_handlers, 'execute').returns(true, 'ok', nil) + local emitted = {} + + state.jobs.set_event_manager({ + emit = function(_, event_name, _) + table.insert(emitted, event_name) + end, + }) + + config.hooks.on_command_before = function(_) + error('hook boom') + end + config.hooks.on_command_after = function(_) + error('hook boom') + end + config.hooks.on_command_finally = function(_) + error('hook boom') + end + + local parsed = command_parse.command({ args = '', range = 0 }, command_registry.get_commands()) + local result = command_dispatch.command(parsed, {}) + + assert.is_true(result.ok) + assert.equal('ok', result.result) + assert.same({ + 'custom.command.hook_error', + 'custom.command.before', + 'custom.command.hook_error', + 'custom.command.after', + 'custom.command.hook_error', + 'custom.command.finally', + }, emitted) + + execute_stub:revert() + end) + + it('runs registry hooks in predictable order and allows ctx rewrite across stages', function() + local module_name = 'test.registry.dispatch_hooks_pipeline' + local original_loaded = package.loaded[module_name] + local original_preload = package.preload[module_name] + local stage_calls = {} + + package.loaded[module_name] = nil + package.preload[module_name] = function() + return { + hooks = { + alpha = { + before = function(ctx) + table.insert(stage_calls, 'alpha.before') + local next_ctx = vim.deepcopy(ctx) + next_ctx.args = { 'alpha' } + next_ctx.intent.args = next_ctx.args + return next_ctx + end, + after = function(ctx) + table.insert(stage_calls, 'alpha.after') + local next_ctx = vim.deepcopy(ctx) + next_ctx.result = tostring(next_ctx.result) .. '-alpha' + return next_ctx + end, + }, + beta = { + before = function(ctx) + table.insert(stage_calls, 'beta.before') + local next_ctx = vim.deepcopy(ctx) + table.insert(next_ctx.args, 'beta') + next_ctx.intent.args = next_ctx.args + return next_ctx + end, + after = function(ctx) + table.insert(stage_calls, 'beta.after') + local next_ctx = vim.deepcopy(ctx) + next_ctx.result = tostring(next_ctx.result) .. '-beta' + return next_ctx + end, + }, + }, + } + end + + command_registry.setup({ + builtin = { commands = true, slash = true }, + user = { + dispatch_hooks_pipeline = module_name, + }, + conflict_policy = 'error', + }) + + config.hooks.on_command_after = function(ctx) + table.insert(stage_calls, 'config.after') + local next_ctx = vim.deepcopy(ctx) + next_ctx.result = tostring(next_ctx.result) .. '-config' + return next_ctx + end + + local execute_stub = stub(command_handlers, 'execute').invokes(function(_, _, args) + return true, table.concat(args, '+'), nil + end) + + local parsed = command_parse.command({ args = '', range = 0 }, command_registry.get_commands()) + local result = command_dispatch.command(parsed, {}) + + assert.is_true(result.ok) + assert.equal('alpha+beta-alpha-beta-config', result.result) + assert.same({ 'alpha', 'beta' }, result.intent.args) + assert.same({ + 'alpha.before', + 'beta.before', + 'alpha.after', + 'beta.after', + 'config.after', + }, stage_calls) + + execute_stub:revert() + package.loaded[module_name] = original_loaded + package.preload[module_name] = original_preload + command_registry.setup(config.extensions) + end) + + it('dispatches commands backed by extension handlers', function() + local module_name = 'test.registry.dispatch_handler' + local original_loaded = package.loaded[module_name] + local original_preload = package.preload[module_name] + + package.loaded[module_name] = nil + package.preload[module_name] = function() + return { + commands = { + custom = { + desc = 'custom command', + handler_id = 'custom', + }, + }, + handlers = { + custom = function(_, args) + return args[1] + end, + }, + } + end + + command_registry.setup({ + builtin = { commands = false, slash = false }, + user = { + dispatch_handler = module_name, + }, + conflict_policy = 'error', + }) + + local parsed = command_parse.command({ args = 'custom pong', range = 0 }, command_registry.get_commands()) + local result = command_dispatch.command(parsed, {}) + + assert.is_true(result.ok) + assert.equal('pong', result.result) + assert.equal('custom', result.intent.handler_id) + + package.loaded[module_name] = original_loaded + package.preload[module_name] = original_preload + command_registry.setup(config.extensions) + end) +end) diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua new file mode 100644 index 00000000..ce77bd2a --- /dev/null +++ b/tests/unit/commands_handlers_spec.lua @@ -0,0 +1,228 @@ +local assert = require('luassert') + +describe('opencode.commands.handlers', function() + local tracked_modules = { + 'opencode.registry', + 'opencode.registry.loader', + 'opencode.registry.extensions.commands', + 'opencode.registry.builtin_commands', + 'opencode.core', + 'opencode.state', + 'opencode.promise', + 'opencode.commands.handlers', + 'opencode.commands.handlers.window', + 'opencode.commands.handlers.agent', + 'opencode.commands.handlers.workflow', + 'opencode.commands.handlers.session', + 'opencode.commands.handlers.diff', + 'opencode.commands.handlers.permission', + 'test.registry.user_handler', + } + + local original_loaded = {} + local original_preload = {} + + before_each(function() + original_loaded = {} + original_preload = {} + for _, module_name in ipairs(tracked_modules) do + original_loaded[module_name] = package.loaded[module_name] + original_preload[module_name] = package.preload[module_name] + package.loaded[module_name] = nil + package.preload[module_name] = nil + end + end) + + after_each(function() + for _, module_name in ipairs(tracked_modules) do + package.loaded[module_name] = original_loaded[module_name] + package.preload[module_name] = original_preload[module_name] + end + end) + + it('fails fast when duplicate handler_id is registered', function() + package.preload['opencode.commands.handlers.window'] = function() + return { + handlers = { + duplicate = function() end, + }, + } + end + package.preload['opencode.commands.handlers.session'] = function() + return { + handlers = { + duplicate = function() end, + }, + } + end + package.preload['opencode.commands.handlers.diff'] = function() + return { handlers = {} } + end + package.preload['opencode.commands.handlers.permission'] = function() + return { handlers = {} } + end + package.preload['opencode.commands.handlers.agent'] = function() + return { handlers = {} } + end + package.preload['opencode.commands.handlers.workflow'] = function() + return { handlers = {} } + end + + local ok, err = pcall(require, 'opencode.commands.handlers') + + assert.is_false(ok) + assert.match("Duplicate handler_id 'duplicate'", err) + assert.match('opencode%.commands%.handlers%.window', err) + assert.match('opencode%.commands%.handlers%.session', err) + end) + + it('does not require registry from handler modules', function() + package.preload['opencode.registry'] = function() + error('registry should not be required by handlers') + end + + local ok, test_err = pcall(function() + require('opencode.commands.handlers.window') + require('opencode.commands.handlers.agent') + require('opencode.commands.handlers.workflow') + require('opencode.commands.handlers.session') + require('opencode.commands.handlers.diff') + require('opencode.commands.handlers.permission') + end) + + if not ok then + error(test_err) + end + end) + + it('session handler does not require api module', function() + package.preload['opencode.api'] = function() + error('api should not be required by session handler') + end + + local ok, test_err = pcall(function() + require('opencode.commands.handlers.session') + end) + + if not ok then + error(test_err) + end + end) + + it('routes session new with title to open_input_new_session_with_title', function() + local session_handler = require('opencode.commands.handlers.session') + local original_with_title = session_handler.actions.open_input_new_session_with_title + local original_without_title = session_handler.actions.open_input_new_session + + session_handler.actions.open_input_new_session_with_title = function(title) + return title + end + session_handler.actions.open_input_new_session = function() + error('should not call open_input_new_session when title is provided') + end + + local result = session_handler.handlers.session({}, { 'new', 'My', 'Session' }) + + session_handler.actions.open_input_new_session_with_title = original_with_title + session_handler.actions.open_input_new_session = original_without_title + + assert.equal('My Session', result) + end) + + it('normalizes handler exceptions via execute', function() + local window_handler = require('opencode.commands.handlers.window') + local original_toggle = window_handler.handlers.toggle + window_handler.handlers.toggle = function() + error('boom') + end + + local handlers = require('opencode.commands.handlers') + + local ok, result, err = handlers.execute('toggle', {}, {}, nil) + + window_handler.handlers.toggle = original_toggle + + assert.is_true(ok) + assert.is_nil(result) + assert.same({ + code = 'handler_exception', + message = 'Command handler failed: toggle', + handler_id = 'toggle', + }, err) + end) + + it('keeps command semantic validation in window handler (open target)', function() + local handlers = require('opencode.commands.handlers') + + local ok, result, err = handlers.execute('open', {}, { 'sideways' }, nil) + + assert.is_true(ok) + assert.is_nil(result) + assert.same({ + code = 'invalid_arguments', + message = 'Invalid target. Use: input or output', + handler_id = 'open', + }, err) + end) + + it('keeps command semantic routing in diff revert handler (session target -> nil snapshot)', function() + local called = {} + local diff_handler = require('opencode.commands.handlers.diff') + local original_revert_all = diff_handler.actions.diff_revert_all + local original_revert_this = diff_handler.actions.diff_revert_this + local original_revert_all_last_prompt = diff_handler.actions.diff_revert_all_last_prompt + local original_revert_this_last_prompt = diff_handler.actions.diff_revert_this_last_prompt + + diff_handler.actions.diff_revert_all = function(snapshot_id) + called.scope = 'all' + called.snapshot_id = snapshot_id + end + diff_handler.actions.diff_revert_this = function(_) + error('should not call diff_revert_this for all scope') + end + diff_handler.actions.diff_revert_all_last_prompt = function() + error('should not call last_prompt path for session target') + end + diff_handler.actions.diff_revert_this_last_prompt = function() + error('should not call last_prompt path for session target') + end + + diff_handler.handlers.revert({}, { 'all', 'session' }) + + diff_handler.actions.diff_revert_all = original_revert_all + diff_handler.actions.diff_revert_this = original_revert_this + diff_handler.actions.diff_revert_all_last_prompt = original_revert_all_last_prompt + diff_handler.actions.diff_revert_this_last_prompt = original_revert_this_last_prompt + + assert.equal('all', called.scope) + assert.is_nil(called.snapshot_id) + end) + + it('executes registry extension handlers through command handlers executor', function() + package.preload['test.registry.user_handler'] = function() + return { + handlers = { + custom = function(_, args) + return table.concat(args, '-') + end, + }, + } + end + + local registry = require('opencode.registry') + registry.setup({ + builtin = { commands = false, slash = false }, + user = { + user_handler = 'test.registry.user_handler', + }, + conflict_policy = 'error', + }) + + local handlers = require('opencode.commands.handlers') + local ok, result, err = handlers.execute('custom', {}, { 'a', 'b' }, nil) + + assert.is_true(ok) + assert.equal('a-b', result) + assert.is_nil(err) + end) +end) diff --git a/tests/unit/commands_parse_spec.lua b/tests/unit/commands_parse_spec.lua new file mode 100644 index 00000000..e5dcc70f --- /dev/null +++ b/tests/unit/commands_parse_spec.lua @@ -0,0 +1,96 @@ +local assert = require('luassert') +local command_parse = require('opencode.commands.parse') +local command_registry = require('opencode.registry') + +describe('opencode.commands.parse', function() + it('parses empty args to toggle intent', function() + local result = command_parse.command({ args = '', range = 0 }, command_registry.get_commands()) + + assert.is_true(result.ok) + assert.equal('toggle', result.intent.handler_id) + assert.same({}, result.intent.args) + assert.is_nil(result.intent.range) + assert.same({ + args = '', + argv = {}, + subcommand = 'toggle', + }, result.intent.raw) + end) + + it('returns stable unknown subcommand parse error', function() + local result = command_parse.command({ args = 'not_real', range = 0 }, command_registry.get_commands()) + + assert.is_false(result.ok) + assert.same({ + code = 'unknown_subcommand', + message = 'Unknown subcommand: not_real', + subcommand = 'not_real', + }, result.error) + assert.is_nil(result.intent) + end) + + it('returns stable missing handler parse error', function() + local defs = { + broken = { + desc = 'broken command', + }, + } + + local result = command_parse.command({ args = 'broken', range = 0 }, defs) + + assert.is_false(result.ok) + assert.same({ + code = 'missing_handler', + message = 'Command is missing handler: broken', + subcommand = 'broken', + }, result.error) + assert.is_nil(result.intent) + end) + + it('parses range and argv without executing handlers', function() + local result = command_parse.command({ + args = 'quick_chat hello world', + range = 2, + line1 = 3, + line2 = 6, + }, command_registry.get_commands()) + + assert.is_true(result.ok) + assert.equal('quick_chat', result.intent.handler_id) + assert.same({ 'hello', 'world' }, result.intent.args) + assert.same({ start = 3, stop = 6 }, result.intent.range) + assert.same({ + args = 'quick_chat hello world', + argv = { 'quick_chat', 'hello', 'world' }, + subcommand = 'quick_chat', + }, result.intent.raw) + end) + + it('keeps diff default open behavior when nested subcommand is omitted', function() + local result = command_parse.command({ args = 'diff', range = 0 }, command_registry.get_commands()) + + assert.is_true(result.ok) + assert.equal('diff', result.intent.handler_id) + assert.same({}, result.intent.args) + end) + + it('validates nested subcommand from command schema without hardcoded command names', function() + local defs = { + custom = { + desc = 'custom command', + handler_id = 'custom', + completions = { 'run' }, + nested_subcommand = { allow_empty = false }, + }, + } + + local result = command_parse.command({ args = 'custom', range = 0 }, defs) + + assert.is_false(result.ok) + assert.same({ + code = 'invalid_subcommand', + message = 'Invalid custom subcommand. Use: run', + subcommand = 'custom', + }, result.error) + end) +end) diff --git a/tests/unit/commands_router_spec.lua b/tests/unit/commands_router_spec.lua new file mode 100644 index 00000000..b32f3b41 --- /dev/null +++ b/tests/unit/commands_router_spec.lua @@ -0,0 +1,72 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local router = require('opencode.commands.router') + +describe('opencode.commands.router', function() + it('maps open_input API method to open input command argv', function() + local argv = router.resolve_command_argv('open_input') + + assert.same({ 'open', 'input' }, argv) + end) + + it('does not apply diff revert semantic defaults in router', function() + local argv = router.resolve_command_argv('diff_revert_all') + + assert.is_nil(argv) + end) + + it('does not parse diff revert semantic args in router', function() + local argv = router.resolve_command_argv('diff_revert_this', { 'snap_123' }) + + assert.is_nil(argv) + end) + + it('does not map restore API methods in router', function() + assert.is_nil(router.resolve_command_argv('diff_restore_snapshot_file')) + assert.is_nil(router.resolve_command_argv('diff_restore_snapshot_all')) + end) + + it('keeps non-routable legacy API methods as invalid_command', function() + local routed, err = router.route_api_method({}, 'diff_revert_all') + + assert.is_false(routed) + assert.same({ + code = 'invalid_command', + message = 'Unknown command method: diff_revert_all', + subcommand = 'diff_revert_all', + }, err) + end) + + it('returns false for unknown API method routing', function() + local route_stub = stub(router, 'route_command_argv') + + local routed, err = router.route_api_method({}, 'unknown_method') + + assert.is_false(routed) + assert.same({ + code = 'invalid_command', + message = 'Unknown command method: unknown_method', + subcommand = 'unknown_method', + }, err) + assert.stub(route_stub).was_not_called() + route_stub:revert() + end) + + it('exposes routable capability checks for entry modules', function() + assert.is_true(router.can_route_api_method('toggle')) + assert.is_false(router.can_route_api_method('unknown_method')) + end) + + it('routes API method with range through command argv router', function() + local route_stub = stub(router, 'route_command_argv').returns('ok') + local range = { start = 3, stop = 5 } + local api = {} + + local routed, result = router.route_api_method(api, 'select_session', nil, range) + + assert.is_true(routed) + assert.equal('ok', result) + assert.stub(route_stub).was_called_with({ 'session', 'select' }, range, api) + route_stub:revert() + end) +end) diff --git a/tests/unit/entry_dependency_guard_spec.lua b/tests/unit/entry_dependency_guard_spec.lua new file mode 100644 index 00000000..54171357 --- /dev/null +++ b/tests/unit/entry_dependency_guard_spec.lua @@ -0,0 +1,72 @@ +local assert = require('luassert') + +describe('entry dependency guards', function() + ---@param argv string[] + ---@return integer, string + local function run_grep(argv) + local output = vim.fn.systemlist(argv) + return vim.v.shell_error, table.concat(output, '\n') + end + + it('keeps OpencodeCommandApi free of private _route_ fields', function() + local lines = vim.fn.readfile('lua/opencode/types.lua') + local content = table.concat(lines, '\n') + + assert.is_nil(content:match('%-%-@field%s+_route_[%w_]+')) + end) + + it('prevents command entry modules from depending on api._route_*', function() + local guarded_files = { + 'lua/opencode/commands/init.lua', + 'lua/opencode/commands/slash.lua', + 'lua/opencode/keymap.lua', + } + + for _, path in ipairs(guarded_files) do + local content = table.concat(vim.fn.readfile(path), '\n') + assert.is_nil(content:match('api%._route_[%w_]+'), path .. ' should not depend on api._route_*') + end + end) + + it('blocks api member access in command handlers directory', function() + local code, output = run_grep({ 'grep', '-R', '-nE', 'api\\[|api\\.', 'lua/opencode/commands/handlers' }) + + assert.equal(1, code, output ~= '' and output or 'handlers must not reference api[...] or api.*') + end) + + it('blocks dynamic api indexing in slash/keymap entry modules', function() + local code, output = run_grep({ + 'grep', + '-nE', + 'api\\[', + 'lua/opencode/commands/slash.lua', + 'lua/opencode/keymap.lua', + }) + + assert.equal(1, code, output ~= '' and output or 'slash/keymap must not use api[...] dynamic access') + end) + + it('blocks dynamic usecase member indexing in command handlers directory', function() + local code, output = run_grep({ + 'grep', + '-R', + '-nE', + 'usecase\\s*\\[|usecase\\s*\\(\\)\\s*\\[', + 'lua/opencode/commands/handlers', + }) + + assert.equal(1, code, output ~= '' and output or 'handlers must not dynamically index usecase methods') + end) + + it('keeps command entry routing on router boundary', function() + local init_content = table.concat(vim.fn.readfile('lua/opencode/commands/init.lua'), '\n') + local slash_content = table.concat(vim.fn.readfile('lua/opencode/commands/slash.lua'), '\n') + + assert.is_not_nil(init_content:match('router%.route_command%(')) + assert.is_nil(init_content:match("require%('opencode%.api'%)")) + + assert.is_not_nil(slash_content:match('router%.route_api_method%(')) + assert.is_not_nil(slash_content:match('router%.route_command_argv%(')) + assert.is_nil(slash_content:match("require%('opencode%.commands%.dispatch'%)")) + end) +end) diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua index 9acedda2..2cc7e1ff 100644 --- a/tests/unit/keymap_spec.lua +++ b/tests/unit/keymap_spec.lua @@ -13,7 +13,14 @@ describe('opencode.keymap', function() -- Mock the API module to break circular dependency local mock_api + local mock_registry + local mock_router local keymap + local routed_commands + local toggle_calls + local submit_calls + local ad_hoc_calls + local next_prompt_history_calls before_each(function() set_keymaps = {} @@ -36,20 +43,56 @@ describe('opencode.keymap', function() end -- Mock the API module before requiring keymap + routed_commands = {} + toggle_calls = 0 + submit_calls = 0 + ad_hoc_calls = 0 + next_prompt_history_calls = 0 mock_api = { open_input = function() end, - toggle = function() end, - submit_input_prompt = function() end, + toggle = function() + toggle_calls = toggle_calls + 1 + end, + submit_input_prompt = function() + submit_calls = submit_calls + 1 + end, + next_prompt_history = function() + next_prompt_history_calls = next_prompt_history_calls + 1 + end, permission_accept = function() end, permission_accept_all = function() end, permission_deny = function() end, - commands = { - open_input = { desc = 'Open input window' }, - toggle = { desc = 'Toggle opencode windows' }, - submit_input_prompt = { desc = 'Submit input prompt' }, - }, + ad_hoc_action = function() + ad_hoc_calls = ad_hoc_calls + 1 + end, + } + mock_router = { + resolve_command_argv = function(func_name, _) + if func_name == 'toggle' then + return { 'toggle' } + end + if func_name == 'open_input' then + return { 'open', 'input' } + end + return nil + end, + route_command_argv = function(argv) + table.insert(routed_commands, vim.deepcopy(argv)) + end, + } + mock_registry = { + get_commands = function(...) + assert.equal(0, select('#', ...), 'registry.get_commands should be called without arguments') + return { + open_input = { desc = 'Open input window' }, + toggle = { desc = 'Toggle opencode windows' }, + submit_input_prompt = { desc = 'Submit input prompt' }, + } + end, } package.loaded['opencode.api'] = mock_api + package.loaded['opencode.registry'] = mock_registry + package.loaded['opencode.commands.router'] = mock_router -- Mock the state module local mock_state = { @@ -73,44 +116,13 @@ describe('opencode.keymap', function() -- Clean up package loading package.loaded['opencode.keymap'] = nil package.loaded['opencode.api'] = nil + package.loaded['opencode.registry'] = nil + package.loaded['opencode.commands.router'] = nil package.loaded['opencode.state'] = nil package.loaded['opencode.config'] = nil end) describe('normalize_keymap', function() - it('shows error message for unknown API functions', function() - local notify_calls = {} - local original_notify = vim.notify - - -- Mock vim.notify to capture error messages - vim.notify = function(message, level) - table.insert(notify_calls, { message = message, level = level }) - end - - local test_keymap = { - editor = { - ['invalid'] = { 'nonexistent_api_function' }, - }, - } - - keymap.setup(test_keymap) - - -- Should have one error notification - assert.equal(1, #notify_calls, 'Should have one error notification') - assert.equal(vim.log.levels.WARN, notify_calls[1].level, 'Should be an error level notification') - assert.match( - 'No action found for keymap: invalid %-> nonexistent_api_function', - notify_calls[1].message, - 'Should mention the missing keymap action' - ) - - -- Should not have set up any keymap for the invalid function - assert.equal(0, #set_keymaps, 'No keymaps should be set for invalid API functions') - - -- Restore original notify - vim.notify = original_notify - end) - it('uses custom description from config_entry', function() local test_keymap = { editor = { @@ -159,70 +171,92 @@ describe('opencode.keymap', function() assert.is_not_nil(keymap_entry.opts.desc, 'Should have a description from API fallback') assert.equal('Toggle opencode windows', keymap_entry.opts.desc) end) - end) - describe('setup_window_keymaps', function() - it('handles unknown API functions with error message', function() + it('routes command-like keymaps through command dispatch helper', function() + local test_keymap = { + editor = { + ['test'] = { 'toggle' }, + }, + } + + keymap.setup(test_keymap) + + assert.equal(1, #set_keymaps, 'Should set up 1 keymap') + set_keymaps[1].callback() + + assert.equal(1, #routed_commands, 'Should route through command argv helper') + assert.same({ 'toggle' }, routed_commands[1]) + assert.equal(0, toggle_calls, 'Direct api.toggle should not be called') + end) + + it('does not fallback to arbitrary api method names', function() local notify_calls = {} local original_notify = vim.notify - -- Mock vim.notify to capture error messages vim.notify = function(message, level) table.insert(notify_calls, { message = message, level = level }) end - local bufnr = vim.api.nvim_create_buf(false, true) - local window_keymap_config = { - [''] = { 'nonexistent_window_function' }, + local test_keymap = { + editor = { + ['adhoc'] = { 'ad_hoc_action' }, + }, } - keymap.setup_window_keymaps(window_keymap_config, bufnr) + keymap.setup(test_keymap) - -- Should have one error notification - assert.equal(1, #notify_calls, 'Should have one error notification') - assert.equal(vim.log.levels.WARN, notify_calls[1].level, 'Should be an error level notification') - assert.match( - 'No action found for keymap: %-> nonexistent_window_function', - notify_calls[1].message, - 'Should mention the missing keymap action' - ) + assert.equal(0, #set_keymaps, 'No keymaps should be set for non-whitelisted api methods') + assert.equal(1, #notify_calls, 'Should have one warning for unknown action') + assert.equal(0, ad_hoc_calls, 'Ad-hoc api method should not be bound') + + vim.notify = original_notify + end) + + it('supports legacy keymap aliases with one-time migration warning', function() + local notify_calls = {} + local original_notify = vim.notify + + vim.notify = function(message, level) + table.insert(notify_calls, { message = message, level = level }) + end + + local test_keymap = { + editor = { + ['h1'] = { 'next_history' }, + ['h2'] = { 'next_history' }, + }, + } + + keymap.setup(test_keymap) - -- Should not have set up any keymap for the invalid function - assert.equal(0, #set_keymaps, 'No keymaps should be set for invalid API functions') + assert.equal(2, #set_keymaps, 'Legacy aliases should still bind keymaps') + assert.equal(1, #notify_calls, 'Legacy alias warning should be emitted once') + assert.match('deprecated', notify_calls[1].message) + assert.match('next_history', notify_calls[1].message) + + set_keymaps[1].callback() + set_keymaps[2].callback() + assert.equal(2, next_prompt_history_calls, 'Legacy alias should route to canonical callback') - -- Cleanup vim.notify = original_notify - vim.api.nvim_buf_delete(bufnr, { force = true }) end) + end) - it('uses custom description for window keymaps', function() + describe('setup_window_keymaps', function() + it('binds non-command keymaps through explicit whitelist callbacks', function() local bufnr = vim.api.nvim_create_buf(false, true) local window_keymap_config = { - [''] = { 'submit_input_prompt', desc = 'Custom submit description' }, - [''] = { function() end, desc = 'Custom function description' }, + [''] = { 'submit_input_prompt' }, } keymap.setup_window_keymaps(window_keymap_config, bufnr) - assert.equal(2, #set_keymaps, 'Should set up 2 window keymaps') + assert.equal(1, #set_keymaps, 'Should set up submit keymap') + set_keymaps[1].callback() - -- Find keymaps by key - local keymaps_by_key = {} - for _, km in ipairs(set_keymaps) do - keymaps_by_key[km.key] = km - end - - -- Check API function keymap uses custom description - local api_keymap = keymaps_by_key[''] - assert.is_not_nil(api_keymap, 'API keymap should exist') - assert.equal('Custom submit description', api_keymap.opts.desc, 'Should use custom description for API function') - - -- Check custom function keymap uses custom description - local func_keymap = keymaps_by_key[''] - assert.is_not_nil(func_keymap, 'Function keymap should exist') - assert.equal('Custom function description', func_keymap.opts.desc, 'Should use custom description for function') + assert.equal(0, #routed_commands, 'Non-command callbacks should not route through command argv helper') + assert.equal(1, submit_calls, 'submit_input_prompt should be called directly from whitelist callback') - -- Cleanup vim.api.nvim_buf_delete(bufnr, { force = true }) end) end) diff --git a/tests/unit/registry_loader_spec.lua b/tests/unit/registry_loader_spec.lua new file mode 100644 index 00000000..a7d5c026 --- /dev/null +++ b/tests/unit/registry_loader_spec.lua @@ -0,0 +1,309 @@ +local assert = require('luassert') +local stub = require('luassert.stub') + +describe('opencode.registry loader', function() + local tracked_modules = { + 'opencode.registry', + 'opencode.registry.loader', + 'opencode.registry.extensions.commands', + 'opencode.registry.extensions.slash', + 'opencode.registry.extensions.hooks', + 'opencode.registry.extensions.context_sources', + 'opencode.context_sources.context_items', + 'opencode.registry.extensions.completion_sources', + 'opencode.registry.extensions.formatters', + 'test.registry.good', + 'test.registry.bad', + 'test.registry.replace', + 'test.registry.capabilities', + 'test.registry.legacy_usecases', + } + + local original_loaded = {} + local original_preload = {} + local notify_stub + + before_each(function() + original_loaded = {} + original_preload = {} + + for _, module_name in ipairs(tracked_modules) do + original_loaded[module_name] = package.loaded[module_name] + original_preload[module_name] = package.preload[module_name] + package.loaded[module_name] = nil + package.preload[module_name] = nil + end + + notify_stub = stub(require('opencode.log'), 'notify') + end) + + after_each(function() + if notify_stub then + notify_stub:revert() + notify_stub = nil + end + + for _, module_name in ipairs(tracked_modules) do + package.loaded[module_name] = original_loaded[module_name] + package.preload[module_name] = original_preload[module_name] + end + end) + + it('isolates extension load failures', function() + package.preload['test.registry.good'] = function() + return { + commands = { + good = { desc = 'Good command', handler_id = 'help' }, + }, + handlers = { + good = function() end, + }, + } + end + + package.preload['test.registry.bad'] = function() + error('boom') + end + + local registry = require('opencode.registry') + registry.setup({ + builtin = { commands = false, slash = false }, + user = { + good = 'test.registry.good', + bad = 'test.registry.bad', + }, + conflict_policy = 'error', + }) + + local commands = registry.get_commands() + local handlers = registry.get_handlers() + assert.truthy(commands.good) + assert.equal('Good command', commands.good.desc) + assert.is_function(handlers.good) + + local errored_extension_logged = false + for _, call in ipairs(notify_stub.calls) do + local message = call.refs[1] + if message:find("extension 'bad'", 1, true) then + errored_extension_logged = true + break + end + end + + assert.is_true(errored_extension_logged) + end) + + it('uses error conflict policy to prevent override', function() + local registry = require('opencode.registry') + registry.setup({ + builtin = { commands = false, slash = false }, + user = {}, + conflict_policy = 'error', + }) + + registry.register('first', { + commands = { + dup = { desc = 'First command', handler_id = 'help' }, + }, + slash_commands = { + ['/dup'] = { desc = 'First slash', api_method = 'help' }, + }, + handlers = { + dup = function() + return 'first' + end, + }, + }) + + assert.has_error(function() + registry.register('second', { + commands = { + dup = { desc = 'Second command', handler_id = 'close' }, + }, + slash_commands = { + ['/dup'] = { desc = 'Second slash', api_method = 'mcp' }, + }, + handlers = { + dup = function() + return 'second' + end, + }, + }) + end) + + assert.equal('First command', registry.get_commands().dup.desc) + assert.equal('First slash', registry.get_slash_commands()['/dup'].desc) + assert.equal('first', registry.get_handlers().dup()) + end) + + it('uses override conflict policy to replace existing definitions', function() + local registry = require('opencode.registry') + registry.setup({ + builtin = { commands = false, slash = false }, + user = {}, + conflict_policy = 'override', + }) + + registry.register('first', { + commands = { + dup = { desc = 'First command', handler_id = 'help' }, + }, + slash_commands = { + ['/dup'] = { desc = 'First slash', api_method = 'help' }, + }, + handlers = { + dup = function() + return 'first' + end, + }, + }) + + registry.register('second', { + commands = { + dup = { desc = 'Second command', handler_id = 'close' }, + }, + slash_commands = { + ['/dup'] = { desc = 'Second slash', api_method = 'mcp' }, + }, + handlers = { + dup = function() + return 'second' + end, + }, + }) + + assert.equal('Second command', registry.get_commands().dup.desc) + assert.equal('Second slash', registry.get_slash_commands()['/dup'].desc) + assert.equal('second', registry.get_handlers().dup()) + end) + + it('supports disabling builtin and replacing with user extension', function() + package.preload['test.registry.replace'] = function() + return { + commands = { + help = { desc = 'Custom help command', handler_id = 'help' }, + }, + slash_commands = { + ['/help'] = { desc = 'Custom help slash', api_method = 'help' }, + }, + } + end + + local registry = require('opencode.registry') + registry.setup({ + builtin = { commands = false, slash = false }, + user = { + replacement = 'test.registry.replace', + }, + conflict_policy = 'error', + }) + + local commands = registry.get_commands() + local slash_commands = registry.get_slash_commands() + + assert.equal('Custom help command', commands.help.desc) + assert.is_nil(commands.open) + assert.equal('Custom help slash', slash_commands['/help'].desc) + assert.is_nil(slash_commands['/models']) + end) + + it('loads and reads hooks/context_sources/completion_sources/formatters', function() + package.preload['test.registry.capabilities'] = function() + return { + hooks = { + sample_hook = function() end, + }, + context_sources = { + sample_context = { + resolve = function() + return {} + end, + }, + }, + completion_sources = { + sample_completion = { + complete = function() + return {} + end, + }, + }, + formatters = { + sample_formatter = function(value) + return value + end, + }, + } + end + + local registry = require('opencode.registry') + registry.setup({ + builtin = { commands = false, slash = false }, + user = { + capabilities = 'test.registry.capabilities', + }, + conflict_policy = 'error', + }) + + assert.is_function(registry.get_hooks().sample_hook) + assert.is_table(registry.get_context_sources().sample_context) + assert.is_table(registry.get_completion_sources().sample_completion) + assert.is_function(registry.get_formatters().sample_formatter) + + assert.is_function(registry.get_capability('hooks').sample_hook) + assert.is_table(registry.get_capability('context_sources').sample_context) + assert.is_table(registry.get_capability('completion_sources').sample_completion) + assert.is_function(registry.get_capability('formatters').sample_formatter) + end) + + it('does not merge legacy usecases when registering directly', function() + local registry = require('opencode.registry') + registry.setup({ + builtin = { commands = false, slash = false }, + user = {}, + conflict_policy = 'error', + }) + + registry.register('legacy_direct', { + usecases = { + legacy_only = function() + return 'legacy' + end, + }, + }) + + assert.is_nil(registry.get_handlers().legacy_only) + end) + + it('adapts legacy usecases at loader boundary with warning', function() + package.preload['test.registry.legacy_usecases'] = function() + return { + usecases = { + legacy = function() + return 'legacy-handler' + end, + }, + } + end + + local registry = require('opencode.registry') + registry.setup({ + builtin = { commands = false, slash = false }, + user = { + legacy = 'test.registry.legacy_usecases', + }, + conflict_policy = 'error', + }) + + assert.equal('legacy-handler', registry.get_handlers().legacy()) + + local warning_count = 0 + for _, call in ipairs(notify_stub.calls) do + local message = call.refs[1] + if message:find('deprecated `usecases`', 1, true) then + warning_count = warning_count + 1 + end + end + + assert.equal(1, warning_count) + end) +end)