diff --git a/README.md b/README.md index a9e4d0b5..cc9dae96 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ require('opencode').setup({ input_window = { [''] = { 'submit_input_prompt', mode = { 'n', 'i' } }, -- Submit prompt (normal mode and insert mode) [''] = { 'close' }, -- Close UI windows - [''] = { 'stop' }, -- Stop opencode while it is running + [''] = { 'cancel' }, -- Cancel opencode request while it is running ['~'] = { 'mention_file', mode = 'i' }, -- Pick a file and add to context. See File Mentions section ['@'] = { 'mention', mode = 'i' }, -- Insert mention (file/agent) ['/'] = { 'slash_commands', mode = 'i' }, -- Pick a command to run in the input window @@ -139,7 +139,7 @@ require('opencode').setup({ }, output_window = { [''] = { 'close' }, -- Close UI windows - [''] = { 'stop' }, -- Stop opencode while it is running + [''] = { 'cancel' }, -- Cancel opencode request while it is running [']]'] = { 'next_message' }, -- Navigate to next message in the conversation ['[['] = { 'prev_message' }, -- Navigate to previous message in the conversation [''] = { 'toggle_pane', mode = { 'n', 'i' } }, -- Toggle between input and output panes @@ -311,55 +311,57 @@ Available icon keys (see implementation at lua/opencode/ui/icons.lua lines 7-29) The plugin provides the following actions that can be triggered via keymaps, commands, slash commands (typed in the input window), or the Lua API: -| Action | Default keymap | Command | API Function | -| --------------------------------------------------- | ------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------- | -| Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` | -| Open input window (current session) | `oi` | `:OpencodeOpenInput` | `require('opencode.api').open_input()` | -| Open input window (new session) | `oI` | `:OpencodeOpenInputNewSession` | `require('opencode.api').open_input_new_session()` | -| Open output window | `oo` | `:OpencodeOpenOutput` | `require('opencode.api').open_output()` | -| Create and switch to a named session | - | `:OpencodeCreateNewSession` | `:OpencodeCreateNewSession ` (user command) | -| Toggle focus opencode / last window | `ot` | `:OpencodeToggleFocus` | `require('opencode.api').toggle_focus()` | -| Close UI windows | `oq` | `:OpencodeClose` | `require('opencode.api').close()` | -| Select and load session | `os` | `:OpencodeSelectSession` | `require('opencode.api').select_session()` | -| **Select and load child session** | `oS` | `:OpencodeSelectChildSession` | `require('opencode.api').select_child_session()` | -| Configure provider and model | `op` | `:OpencodeConfigureProvider` | `require('opencode.api').configure_provider()` | -| Open diff view of changes | `od` | `:OpencodeDiff` | `require('opencode.api').diff_open()` | -| Navigate to next file diff | `o]` | `:OpencodeDiffNext` | `require('opencode.api').diff_next()` | -| Navigate to previous file diff | `o[` | `:OpencodeDiffPrev` | `require('opencode.api').diff_prev()` | -| Close diff view tab | `oc` | `:OpencodeDiffClose` | `require('opencode.api').diff_close()` | -| Revert all file changes since last prompt | `ora` | `:OpencodeRevertAllLastPrompt` | `require('opencode.api').diff_revert_all_last_prompt()` | -| Revert current file changes last prompt | `ort` | `:OpencodeRevertThisLastPrompt` | `require('opencode.api').diff_revert_this_last_prompt()` | -| Revert all file changes since last session | `orA` | `:OpencodeRevertAllSession` | `require('opencode.api').diff_revert_all_session()` | -| Revert current file changes last session | `orT` | `:OpencodeRevertThisSession` | `require('opencode.api').diff_revert_this_session()` | -| Revert all files to a specific snapshot | - | `:OpencodeRevertAllToSnapshot` | `require('opencode.api').diff_revert_all(snapshot_id)` | -| Revert current file to a specific snapshot | - | `:OpencodeRevertThisToSnapshot` | `require('opencode.api').diff_revert_this(snapshot_id)` | -| Restore a file to a restore point | - | `:OpencodeRestoreSnapshotFile` | `require('opencode.api').diff_restore_snapshot_file(restore_point_id)` | -| Restore all files to a restore point | - | `:OpencodeRestoreSnapshotAll` | `require('opencode.api').diff_restore_snapshot_all(restore_point_id)` | -| Initialize/update AGENTS.md file | - | `:OpencodeInit` | `require('opencode.api').initialize()` | -| Run prompt (continue session) [Run opts](#run-opts) | - | `:OpencodeRun ` | `require('opencode.api').run("prompt", opts)` | -| Run prompt (new session) [Run opts](#run-opts) | - | `:OpencodeRunNewSession ` | `require('opencode.api').run_new_session("prompt", opts)` | -| Stop opencode while it is running | `` | `:OpencodeStop` | `require('opencode.api').stop()` | -| Set mode to Build | - | `:OpencodeAgentBuild` | `require('opencode.api').agent_build()` | -| Set mode to Plan | - | `:OpencodeAgentPlan` | `require('opencode.api').agent_plan()` | -| Select and switch mode/agent | - | `:OpencodeAgentSelect` | `require('opencode.api').select_agent()` | -| Display list of availale mcp servers | - | `:OpencodeMCP` | `require('opencode.api').mcp()` | -| Run user commands | - | `:OpencodeRunUserCommand` | `require('opencode.api').run_user_command()` | -| Share current session and get a link | - | `:OpencodeShareSession` / `/share` | `require('opencode.api').share()` | -| Unshare current session (disable link) | - | `:OpencodeUnshareSession` / `/unshare` | `require('opencode.api').unshare()` | -| Compact current session (summarize) | - | `:OpencodeCompactSession` / `/compact` | `require('opencode.api').compact_session()` | -| Undo last opencode action | - | `:OpencodeUndo` / `/undo` | `require('opencode.api').undo()` | -| Redo last opencode action | - | `:OpencodeRedo` / `/redo` | `require('opencode.api').redo()` | -| Respond to permission requests (accept once) | `a` (window) / `opa` (global) | `:OpencodePermissionAccept` | `require('opencode.api').permission_accept()` | -| Respond to permission requests (accept all) | `A` (window) / `opA` (global) | `:OpencodePermissionAcceptAll` | `require('opencode.api').permission_accept_all()` | -| Respond to permission requests (deny) | `d` (window) / `opd` (global) | `:OpencodePermissionDeny` | `require('opencode.api').permission_deny()` | -| Insert mention (file/ agent) | `@` | - | - | -| [Pick a file and add to context](#file-mentions) | `~` | - | - | -| Navigate to next message | `]]` | - | - | -| Navigate to previous message | `[[` | - | - | -| Navigate to previous prompt in history | `` | - | `require('opencode.api').prev_history()` | -| Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` | -| Toggle input/output panes | `` | - | - | -| Swap Opencode pane left/right | `ox` | `:OpencodeSwapPosition` | `require('opencode.api').swap_position()` | +> **Note:** Commands have been restructured into a single `:Opencode` command with subcommands. Legacy `Opencode*` commands (e.g., `:OpencodeOpenInput`) are still available by default but will be removed in a future version. Update your scripts and workflows to use the new nested syntax. + +| Action | Default keymap | Command | API Function | +| --------------------------------------------------- | ------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | +| Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` | +| Open input window (current session) | `oi` | `:Opencode open input` | `require('opencode.api').open_input()` | +| Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` | +| Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` | +| Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) | +| Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` | +| Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` | +| Select and load session | `os` | `:Opencode session select` | `require('opencode.api').select_session()` | +| **Select and load child session** | `oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` | +| Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` | +| Open diff view of changes | `od` | `:Opencode diff open` | `require('opencode.api').diff_open()` | +| Navigate to next file diff | `o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` | +| Navigate to previous file diff | `o[` | `:Opencode diff prev` | `require('opencode.api').diff_prev()` | +| Close diff view tab | `oc` | `:Opencode diff close` | `require('opencode.api').diff_close()` | +| Revert all file changes since last prompt | `ora` | `:Opencode revert all prompt` | `require('opencode.api').diff_revert_all_last_prompt()` | +| Revert current file changes last prompt | `ort` | `:Opencode revert this prompt` | `require('opencode.api').diff_revert_this_last_prompt()` | +| Revert all file changes since last session | `orA` | `:Opencode revert all session` | `require('opencode.api').diff_revert_all_session()` | +| Revert current file changes last session | `orT` | `:Opencode revert this session` | `require('opencode.api').diff_revert_this_session()` | +| Revert all files to a specific snapshot | - | `:Opencode revert all_to_snapshot` | `require('opencode.api').diff_revert_all(snapshot_id)` | +| Revert current file to a specific snapshot | - | `:Opencode revert this_to_snapshot` | `require('opencode.api').diff_revert_this(snapshot_id)` | +| Restore a file to a restore point | - | `:Opencode restore snapshot_file` | `require('opencode.api').diff_restore_snapshot_file(restore_point_id)` | +| Restore all files to a restore point | - | `:Opencode restore snapshot_all` | `require('opencode.api').diff_restore_snapshot_all(restore_point_id)` | +| Initialize/update AGENTS.md file | - | `:Opencode session agents_init` | `require('opencode.api').initialize()` | +| Run prompt (continue session) [Run opts](#run-opts) | - | `:Opencode run ` | `require('opencode.api').run("prompt", opts)` | +| Run prompt (new session) [Run opts](#run-opts) | - | `:Opencode run new_session ` | `require('opencode.api').run_new_session("prompt", opts)` | +| Cancel opencode while it is running | `` | `:Opencode cancel` | `require('opencode.api').cancel()` | +| Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` | +| Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` | +| Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` | +| Display list of availale mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` | +| Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` | +| Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` | +| Unshare current session (disable link) | - | `:Opencode session unshare` / `/unshare` | `require('opencode.api').unshare()` | +| Compact current session (summarize) | - | `:Opencode session compact` / `/compact` | `require('opencode.api').compact_session()` | +| Undo last opencode action | - | `:Opencode undo` / `/undo` | `require('opencode.api').undo()` | +| Redo last opencode action | - | `:Opencode redo` / `/redo` | `require('opencode.api').redo()` | +| Respond to permission requests (accept once) | `a` (window) / `opa` (global) | `:Opencode permission accept` | `require('opencode.api').permission_accept()` | +| Respond to permission requests (accept all) | `A` (window) / `opA` (global) | `:Opencode permission accept_all` | `require('opencode.api').permission_accept_all()` | +| Respond to permission requests (deny) | `d` (window) / `opd` (global) | `:Opencode permission deny` | `require('opencode.api').permission_deny()` | +| Insert mention (file/ agent) | `@` | - | - | +| [Pick a file and add to context](#file-mentions) | `~` | - | - | +| Navigate to next message | `]]` | - | - | +| Navigate to previous message | `[[` | - | - | +| Navigate to previous prompt in history | `` | - | `require('opencode.api').prev_history()` | +| Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` | +| Toggle input/output panes | `` | - | - | +| Swap Opencode pane left/right | `ox` | `:Opencode swap position` | `require('opencode.api').swap_position()` | --- @@ -382,8 +384,8 @@ You can pass additional options when running a prompt via command or API: Run a prompt in a new session using the Plan agent and disabling current file context: ```vim -:OpencodeRunNewSession "Please help me plan a new feature" agent=plan context.current_file.enabled=false -:OpencodeRun "Fix the bug in the current file" model=github-copilot/claude-sonned-4 +:Opencode run new_session "Please help me plan a new feature" agent=plan context.current_file.enabled=false +:Opencode run "Fix the bug in the current file" model=github-copilot/claude-sonned-4 ``` ##👮 Permissions @@ -458,7 +460,7 @@ You can run predefined user commands and built-in slash commands from the input - `/compact` — Compact (summarize) the current session - `/undo` — Undo the last opencode action - `/redo` — Redo the last undone action -- `/init` — Initialize/update AGENTS.md +- `/agents_init` — Initialize/update AGENTS.md - `/help` — Show help - `/mcp` — Show MCP servers - `/models` — Switch provider/model @@ -472,7 +474,7 @@ You can run predefined user commands and built-in slash commands from the input - `.opencode/command/` (project-specific) - `command/` (global, in config directory) -You can also run user commands by name with `:OpencodeRunUserCommand ` or `/run_user_command `. +You can also run user commands by name with `:Opencode command `. Opencode.nvim contextual actions diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 9dd4a04a..48b9cf96 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -62,8 +62,8 @@ function M.configure_provider() core.configure_provider() end -function M.stop() - core.stop() +function M.cancel() + core.cancel() end ---@param prompt string @@ -210,6 +210,9 @@ function M.set_review_breakpoint() end function M.prev_history() + if not state.windows then + return + end local prev_prompt = history.prev() if prev_prompt then input_window.set_content(prev_prompt) @@ -218,6 +221,9 @@ function M.prev_history() end function M.next_history() + if not state.windows then + return + end local next_prompt = history.next() if next_prompt then input_window.set_content(next_prompt) @@ -342,6 +348,10 @@ function M.initialize() vim.notify('Failed to create new session', vim.log.levels.ERROR) return end + if 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) @@ -424,20 +434,38 @@ function M.help() local msg = M.with_header({ '### Available Commands', '', - '| Command | Description |', - '|-----------|---------------------|', + '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) - local max_desc_length = (vim.api.nvim_win_get_width(state.windows.output_win) / 2) - 5 + if not state.windows or not state.windows.output_win then + return + end + + local max_desc_length = math.floor((vim.api.nvim_win_get_width(state.windows.output_win) / 1.3) - 5) - for _, def in pairs(M.commands) do + 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('| %-10s | %s |', def.name, desc)) + table.insert(msg, string.format('| %-12s | %-53s |', 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 @@ -484,6 +512,10 @@ function M.run_user_command(name, args) M.open_input() ui.render_output(true) + if not state.active_session then + vim.notify('No active session', vim.log.levels.WARN) + return + end state.api_client :send_command(state.active_session.id, { command = name, @@ -505,7 +537,16 @@ function M.compact_session(current_session) return end - local providerId, modelId = state.current_model:match('^(.-)/(.+)$') + 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, @@ -715,467 +756,423 @@ function M.permission_deny() M.respond_to_permission('reject') end --- Command def/compactinitions that call the API functions M.commands = { - swap_position = { - name = 'OpencodeSwapPosition', - desc = 'Swap Opencode pane left/right', - fn = function() - M.swap_position() - end, - }, - - toggle = { - name = 'Opencode', - desc = 'Open opencode. Close if opened', - fn = function() - M.toggle() - end, - }, - - toggle_focus = { - name = 'OpencodeToggleFocus', - desc = 'Toggle focus between opencode and last window', - fn = function() - M.toggle_focus() - end, - }, - - open_input = { - name = 'OpencodeOpenInput', - desc = 'Opens and focuses on input window on insert mode', - fn = function() - M.open_input() - end, - }, - - open_input_new_session = { - name = 'OpencodeOpenInputNewSession', - slash_cmd = '/new', - desc = 'Opens and focuses on input window on insert mode. Creates a new session', - fn = function() - M.open_input_new_session() - end, - }, - - open_output = { - name = 'OpencodeOpenOutput', - desc = 'Opens and focuses on output window', - fn = function() - M.open_output() - end, - }, - - create_new_session = { - name = 'OpencodeCreateNewSession', - desc = 'Create a new opencode session', - fn = function(opts) - local title = opts.args and opts.args:match('^%s*(.+)') - if title and title ~= '' then - local new_session = core.create_new_session(title) - if not new_session then - vim.notify('Failed to create new session', vim.log.levels.ERROR) - return - end - state.active_session = new_session + 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('Session title cannot be empty', vim.log.levels.ERROR) + vim.notify('Invalid target. Use: input or output', vim.log.levels.ERROR) end end, - args = true, }, close = { - name = 'OpencodeClose', - desc = 'Close UI windows', - fn = function() + desc = 'Close opencode windows', + fn = function(args) M.close() end, }, - stop = { - name = 'OpencodeStop', - desc = 'Stop opencode while it is running', - fn = function() - M.stop() - end, + cancel = { + desc = 'Cancel running request', + fn = M.cancel, }, - select_session = { - name = 'OpencodeSelectSession', - slash_cmd = '/sessions', - desc = 'Select and load a opencode session', - fn = function() - M.select_session() - end, + toggle = { + desc = 'Toggle opencode windows', + fn = M.toggle, }, - select_child_session = { - name = 'OpencodeSelectChildSession', - slash_cmd = '/child-sessions', - desc = 'Select and load a child session of the current session', - fn = function() - M.select_child_session() - end, + toggle_focus = { + desc = 'Toggle focus between opencode and code', + fn = M.toggle_focus, }, toggle_pane = { - name = 'OpencodeTogglePane', - desc = 'Toggle between input and output panes', - fn = function() - M.toggle_pane() - end, + desc = 'Toggle between input/output panes', + fn = M.toggle_pane, }, - configure_provider = { - name = 'OpencodeConfigureProvider', - slash_cmd = '/models', - desc = 'Quick provider and model switch from predefined list', - fn = function() - M.configure_provider() - end, + swap = { + desc = 'Swap pane position left/right', + fn = M.swap_position, }, - run = { - name = 'OpencodeRun', - desc = 'Run opencode with a prompt (continue last session)', - fn = function(opts) - local prompt, rest = opts.args:match('^(.-)%s+(%S+=%S.*)$') - prompt = vim.trim(prompt or opts.args) - local extra_args = util.parse_dot_args(rest or '') - M.run(prompt, extra_args) + session = { + desc = 'Manage sessions (new/select/child/compact/share/unshare)', + completions = { 'new', 'select', 'child', 'compact', 'share', 'unshare', 'agents_init' }, + fn = function(args) + local subcmd = args[1] + if subcmd == 'new' then + local title = table.concat(vim.list_slice(args, 2), ' ') + if title and title ~= '' then + local new_session = core.create_new_session(title) + if not new_session then + vim.notify('Failed to create new session', vim.log.levels.ERROR) + return + end + state.active_session = new_session + M.open_input() + else + M.open_input_new_session() + 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() + else + local valid_subcmds = table.concat(M.commands.session.completions, ', ') + vim.notify('Invalid session subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) + end end, - args = true, }, - run_new_session = { - name = 'OpencodeRunNewSession', - desc = 'Run opencode with a prompt (new session)', - fn = function(opts) - local prompt, rest = opts.args:match('^(.-)%s+(%S+=%S.*)$') - prompt = vim.trim(prompt or opts.args) - local extra_args = util.parse_dot_args(rest or '') - M.run_new_session(prompt, extra_args) - end, - args = true, + undo = { + desc = 'Undo last action', + fn = M.undo, }, - diff_open = { - name = 'OpencodeDiff', - desc = 'Opens a diff tab of a modified file since the last opencode prompt', - fn = function() - M.diff_open() - end, + redo = { + desc = 'Redo last action', + fn = M.redo, }, - diff_next = { - name = 'OpencodeDiffNext', - desc = 'Navigate to next file diff', - fn = function() - M.diff_next() + 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, ', ') + vim.notify('Invalid diff subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) + end end, }, - diff_prev = { - name = 'OpencodeDiffPrev', - desc = 'Navigate to previous file diff', - fn = function() - M.diff_prev() + 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, }, - diff_close = { - name = 'OpencodeDiffClose', - desc = 'Close diff view tab and return to normal editing', - fn = function() - M.diff_close() - end, - }, + restore = { + desc = 'Restore from snapshot (file/all)', + completions = { 'file', 'all' }, + fn = function(args) + local scope = args[1] + local snapshot_id = args[2] - diff_revert_all_last_prompt = { - name = 'OpencodeRevertAllLastPrompt', - desc = 'Revert all file changes since the last opencode prompt', - fn = function() - M.diff_revert_all_last_prompt() - end, - }, + if not snapshot_id then + vim.notify('Snapshot ID required', vim.log.levels.ERROR) + return + end - diff_revert_this_last_prompt = { - name = 'OpencodeRevertThisLastPrompt', - desc = 'Revert current file changes since the last opencode prompt', - fn = function() - M.diff_revert_this_last_prompt() + 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, }, - diff_revert_all_session = { - name = 'OpencodeRevertAllSession', - desc = 'Revert all file changes since the last session', - fn = function() - M.diff_revert_all_session() - end, + breakpoint = { + desc = 'Set review breakpoint', + fn = M.set_review_breakpoint, }, - diff_revert_this_session = { - name = 'OpencodeRevertThisSession', - desc = 'Revert current file changes since the last session', - fn = function() - M.diff_revert_this_session() + 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, ', ') + vim.notify('Invalid agent subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) + end end, }, - diff_revert_all_to_snapshot = { - name = 'OpencodeRevertAllToSnapshot', - desc = 'Revert all file changes to a specific snapshot', - fn = function(snapshot) - if not snapshot then - vim.notify('Snapshot ID is required', vim.log.levels.ERROR) - return - end - M.diff_revert_all(snapshot) - end, - args = true, + models = { + desc = 'Switch provider/model', + fn = M.configure_provider, }, - diff_revert_this_to_snapshot = { - name = 'OpencodeRevertThisToSnapshot', - desc = 'Revert all file changes to a specific snapshot', - fn = function(snapshot) - if not snapshot then - vim.notify('Snapshot ID is required', vim.log.levels.ERROR) + 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 - M.diff_revert_this(snapshot) + M.run(prompt, opts) end, - args = true, }, - diff_restore_snapshot_file = { - name = 'OpencodeRestoreSnapshotFile', - desc = 'Restore a file to a specific restore point', - fn = function(snapshot) - if not snapshot then - vim.notify('Snapshot ID is required', vim.log.levels.ERROR) + 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 - M.diff_restore_snapshot_file(snapshot) + M.run_new_session(prompt, opts) end, - args = true, }, - diff_restore_snapshot_all = { - name = 'OpencodeRestoreSnapshotAll', - desc = 'Restore all files to a specific restore point', - fn = function(snapshot) - if not snapshot then - vim.notify('Snapshot ID is required', vim.log.levels.ERROR) + command = { + desc = 'Run user-defined command', + 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.diff_restore_snapshot_all(snapshot) - end, - args = true, - }, - - set_review_breakpoint = { - name = 'OpencodeSetReviewBreakpoint', - desc = 'Set a review breakpoint to track changes', - fn = function() - M.set_review_breakpoint() - end, - }, - - init = { - name = 'OpencodeInit', - slash_cmd = '/init', - desc = 'Initialize/Update AGENTS.md file', - fn = function() - M.initialize() + M.run_user_command(name, vim.list_slice(args, 2)) end, }, help = { - name = 'OpencodeHelp', - slash_cmd = '/help', - desc = 'Display help message', - fn = function() - M.help() - end, + desc = 'Show this help message', + fn = M.help, }, mcp = { - name = 'OpencodeMCP', - slash_cmd = '/mcp', - desc = 'Display list of mcp servers', - fn = function() - M.mcp() - end, + desc = 'Show MCP server configuration', + fn = M.mcp, }, - opencode_mode_plan = { - name = 'OpencodeAgentPlan', - desc = 'Set opencode agent to `plan`. (Tool calling disabled. No editor context besides selections)', - fn = function() - M.agent_plan() + permission = { + desc = 'Respond to permissions (accept/accept_all/deny)', + completions = { 'accept', 'accept_all', 'deny' }, + fn = function(args) + local subcmd = args[1] + if subcmd == 'accept' then + M.permission_accept() + elseif subcmd == 'accept_all' then + M.permission_accept_all() + elseif subcmd == 'deny' then + M.permission_deny() + else + local valid_subcmds = table.concat(M.commands.permission.completions, ', ') + vim.notify('Invalid permission subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) + end end, }, +} - opencode_mode_build = { - name = 'OpencodeAgentBuild', - desc = 'Set opencode agent to `build`. (Default with full agent capabilities)', - fn = function() - M.agent_build() - end, - }, +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' }, + ['/compact'] = { fn = M.compact_session, desc = 'Compact current session' }, + ['/mcp'] = { fn = M.mcp, desc = 'Show MCP server configuration' }, + ['/models'] = { fn = M.configure_provider, desc = 'Switch provider/model' }, + ['/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' }, + ['/undo'] = { fn = M.undo, desc = 'Undo last action' }, + ['/unshare'] = { fn = M.unshare, desc = 'Unshare current session' }, +} - open_code_select_mode = { - name = 'OpencodeAgentSelect', - slash_cmd = '/agent', - desc = 'Select opencode agent', - fn = function() - M.select_agent() - end, - }, +M.legacy_command_map = { + OpencodeSwapPosition = 'swap', + OpencodeToggleFocus = 'toggle_focus', + OpencodeOpenInput = 'open input', + OpencodeOpenInputNewSession = 'session new', + OpencodeOpenOutput = 'open output', + OpencodeCreateNewSession = 'session new', + OpencodeClose = 'close', + OpencodeStop = 'cancel', + OpencodeSelectSession = 'session select', + OpencodeSelectChildSession = 'session child', + OpencodeTogglePane = 'toggle_pane', + OpencodeConfigureProvider = 'models', + OpencodeRun = 'run', + OpencodeRunNewSession = 'run_new', + OpencodeDiff = 'diff open', + OpencodeDiffNext = 'diff next', + OpencodeDiffPrev = 'diff prev', + OpencodeDiffClose = 'diff close', + OpencodeRevertAllLastPrompt = 'revert all prompt', + OpencodeRevertThisLastPrompt = 'revert this prompt', + OpencodeRevertAllSession = 'revert all session', + OpencodeRevertThisSession = 'revert this session', + OpencodeRevertAllToSnapshot = 'revert all', + OpencodeRevertThisToSnapshot = 'revert this', + OpencodeRestoreSnapshotFile = 'restore file', + OpencodeRestoreSnapshotAll = 'restore all', + OpencodeSetReviewBreakpoint = 'breakpoint', + OpencodeInit = 'session agents_init', + OpencodeHelp = 'help', + OpencodeMCP = 'mcp', + OpencodeAgentPlan = 'agent plan', + OpencodeAgentBuild = 'agent build', + OpencodeAgentSelect = 'agent select', + OpencodeRunUserCommand = 'command', + OpencodeCompactSession = 'session compact', + OpencodeShareSession = 'session share', + OpencodeUnshareSession = 'session unshare', + OpencodeUndo = 'undo', + OpencodeRedo = 'redo', + OpencodePermissionAccept = 'permission accept', + OpencodePermissionAcceptAll = 'permission accept_all', + OpencodePermissionDeny = 'permission deny', +} - run_user_command = { - name = 'OpencodeRunUserCommand', - desc = 'Run a user-defined Opencode command by name', - fn = function(opts) - local parts = vim.split(opts.args or '', '%s+') - local name = parts[1] - if not name or name == '' then - vim.notify('User command name required. Usage: :OpencodeRunUserCommand ', vim.log.levels.ERROR) - return - end - M.run_user_command(name, vim.list_slice(parts, 2)) - end, - args = true, - }, +function M.route_command(opts) + local args = vim.split(opts.args or '', '%s+', { trimempty = true }) - compact_session = { - name = 'OpencodeCompactSession', - desc = 'Compacts the current session by removing unnecessary data', - fn = function() - if not state.active_session then - vim.notify('No active session to compact', vim.log.levels.WARN) - return - end - M.compact_session(state.active_session) - end, - slash_cmd = '/compact', - }, + if #args == 0 then + M.toggle() + return + end - share_session = { - name = 'OpencodeShareSession', - desc = 'Share the current session and get a shareable link', - fn = function() - if not state.active_session then - vim.notify('No active session to share', vim.log.levels.WARN) - return - end - M.share() - end, - slash_cmd = '/share', - }, + local subcommand = args[1] + local subcmd_def = M.commands[subcommand] - unshare_session = { - name = 'OpencodeUnshareSession', - desc = 'Unshare the current session, disabling the shareable link', - fn = function() - if not state.active_session then - vim.notify('No active session to unshare', vim.log.levels.WARN) - return - end - M.unshare() - end, - slash_cmd = '/unshare', - }, + if subcmd_def and subcmd_def.fn then + subcmd_def.fn(vim.list_slice(args, 2)) + else + vim.notify('Unknown subcommand: ' .. subcommand, vim.log.levels.ERROR) + end +end - undo = { - name = 'OpencodeUndo', - desc = 'Undo last opencode action', - fn = function() - if not state.active_session then - vim.notify('No active session to undo', vim.log.levels.WARN) - return - end - M.undo() - end, - slash_cmd = '/undo', - }, +function M.complete_command(arg_lead, cmd_line, cursor_pos) + local parts = vim.split(cmd_line, '%s+', { trimempty = false }) + local num_parts = #parts - redo = { - name = 'OpencodeRedo', - desc = 'Redo last opencode action', - fn = function() - if not state.active_session then - vim.notify('No active session to undo', vim.log.levels.WARN) - return - end - M.redo() - end, - slash_cmd = '/redo', - }, + 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 - permission_accept = { - name = 'OpencodePermissionAccept', - desc = 'Accept current permission request', - fn = function() - M.respond_to_permission('once') - end, - }, + local subcommand = parts[2] + local subcmd_def = M.commands[subcommand] - permission_accept_all = { - name = 'OpencodePermissionAcceptAll', - desc = 'Accept all permission requests', - fn = function() - M.respond_to_permission('always') - end, - }, + if not subcmd_def then + return {} + end - permission_deny = { - name = 'OpencodePermissionDeny', - desc = 'Deny current permission request', - fn = function() - M.respond_to_permission('reject') - end, - }, -} + if num_parts <= 3 and subcmd_def.completions then + return vim.tbl_filter(function(opt) + return vim.startswith(opt, arg_lead) + end, subcmd_def.completions) + end ----@return OpencodeSlashCommand[] -function M.get_slash_commands() - local commands = vim.tbl_filter(function(cmd) - return cmd.slash_cmd and cmd.slash_cmd ~= '' or false - end, M.commands) --[[@as OpencodeSlashCommand[] ]] - - local user_commands = require('opencode.config_file').get_user_commands() - if user_commands then - for name, cfg in pairs(user_commands) do - table.insert(commands, { - slash_cmd = '/' .. name, - desc = 'Run user command: ' .. name, - args = cfg.template and cfg.template:match('$ARGUMENTS') ~= nil, - fn = function(args) - M.run_user_command(name, args) - end, - }) - 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 - table.sort(commands, function(a, b) - return a.slash_cmd < b.slash_cmd - end) + return {} +end + +function M.setup_legacy_commands() + local config = require('opencode.config') + if not config.legacy_commands then + return + end - return commands + for legacy_name, new_route in pairs(M.legacy_command_map) do + vim.api.nvim_create_user_command(legacy_name, function(opts) + vim.notify( + string.format(':%s is deprecated. Use `:Opencode %s` instead', legacy_name, new_route), + vim.log.levels.WARN + ) + vim.cmd('Opencode ' .. new_route) + end, { + desc = 'deprecated', + nargs = '*', + }) + end end -function M.setup() - for _, cmd in pairs(M.commands) do - vim.api.nvim_create_user_command(cmd.name, cmd.fn, { - desc = cmd.desc, - nargs = cmd.args and '+' or 0, +function M.get_slash_commands() + 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, }) end + return result +end + +function M.setup() + vim.api.nvim_create_user_command('Opencode', M.route_command, { + desc = 'Opencode.nvim main command with nested subcommands', + nargs = '*', + complete = M.complete_command, + }) + + M.setup_legacy_commands() end return M diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 0f730489..bfc227e0 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -10,6 +10,7 @@ M.defaults = { preferred_completion = nil, default_global_keymaps = true, default_mode = 'build', + legacy_commands = true, keymap_prefix = 'o', keymap = { editor = { @@ -38,7 +39,7 @@ M.defaults = { }, output_window = { [''] = { 'close' }, - [''] = { 'stop' }, + [''] = { 'cancel' }, [']]'] = { 'next_message' }, ['[['] = { 'prev_message' }, [''] = { 'toggle_pane', mode = { 'n', 'i' } }, @@ -51,7 +52,7 @@ M.defaults = { input_window = { [''] = { 'submit_input_prompt', mode = { 'n', 'i' } }, [''] = { 'close' }, - [''] = { 'stop' }, + [''] = { 'cancel' }, ['~'] = { 'mention_file', mode = 'i' }, ['@'] = { 'mention', mode = 'i' }, ['/'] = { 'slash_commands', mode = 'i' }, diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 3f75f04a..7feade89 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -132,7 +132,7 @@ function M.send_message(prompt, opts) end) :catch(function(err) vim.notify('Error sending message to session: ' .. vim.inspect(err), vim.log.levels.ERROR) - M.stop() + M.cancel() end) end @@ -165,7 +165,7 @@ function M.before_run(opts) local is_new_session = opts and opts.new_session or not state.active_session opts = opts or {} - M.stop() + M.cancel() -- ui.clear_output() M.open({ @@ -193,7 +193,7 @@ function M.configure_provider() end) end -function M.stop() +function M.cancel() if state.windows and state.active_session then if state.is_running() then M._abort_count = M._abort_count + 1 diff --git a/lua/opencode/session.lua b/lua/opencode/session.lua index 28b230ca..4c8f8fe9 100644 --- a/lua/opencode/session.lua +++ b/lua/opencode/session.lua @@ -168,7 +168,7 @@ function M.get_messages(session) end ---Get snapshot IDs from a message's parts ----@param message OpencodeMessage +---@param message OpencodeMessage? ---@return string[]|nil function M.get_message_snapshot_ids(message) if not message then diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 09a157b8..a4cbfcfe 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -35,6 +35,9 @@ ---@field snapshot string ---@field diff string +---@class SessionShareInfo +---@field url string + ---@class Session ---@field workspace string ---@field description string @@ -47,6 +50,7 @@ ---@field snapshot_path string ---@field cache_path string ---@field revert? SessionRevertInfo +---@field share? SessionShareInfo ---@class OpencodeKeymapEntry ---@field [1] string # Function name diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index 1fcaf232..4d6bec69 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -83,8 +83,6 @@ local presets = { local deprecated_warning_shown = false -local deprecated_warning_shown = false - ---Get icon by key, honoring preset and user overrides ---@param key string ---@return string diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 2c8d6d8d..3db2368d 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -7,7 +7,7 @@ local input_window = require('opencode.ui.input_window') local footer = require('opencode.ui.footer') local topbar = require('opencode.ui.topbar') ----@param windows OpencodeWindowState +---@param windows OpencodeWindowState? function M.close_windows(windows) if not windows then return diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index 6b267ae1..4caf879b 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -420,4 +420,38 @@ function M.strdisplaywidth(str) return vim.fn.strdisplaywidth(str) end +--- Parse run command arguments with optional agent, model, and context prefixes. +--- Returns opts table and remaining prompt string. +--- Format: [agent=] [model=] [context=] +--- @param args string[] +--- @return table opts, string prompt +function M.parse_run_args(args) + local opts = {} + local prompt_start_idx = 1 + + for i, token in ipairs(args) do + local agent = token:match('^agent=(.+)$') + local model = token:match('^model=(.+)$') + local context = token:match('^context=(.+)$') + + if agent then + opts.agent = agent + prompt_start_idx = i + 1 + elseif model then + opts.model = model + prompt_start_idx = i + 1 + elseif context then + opts.context = M.parse_dot_args(context:gsub(',', ' ')) + prompt_start_idx = i + 1 + else + break + end + end + + local prompt_tokens = vim.list_slice(args, prompt_start_idx) + local prompt = table.concat(prompt_tokens, ' ') + + return opts, prompt +end + return M diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index d6c1913c..8d4060ed 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -19,7 +19,7 @@ describe('opencode.api', function() end) stub(core, 'open') stub(core, 'run') - stub(core, 'stop') + stub(core, 'cancel') stub(core, 'send_message') stub(ui, 'close_windows') end) @@ -31,19 +31,32 @@ describe('opencode.api', function() describe('commands table', function() it('contains the expected commands with proper structure', function() local expected_commands = { - 'open_input', - 'open_input_new_session', - 'open_output', + 'open', 'close', - 'stop', + 'cancel', + 'toggle', + 'toggle_focus', + 'toggle_pane', + 'session', + 'swap', + 'undo', + 'redo', + 'diff', + 'revert', + 'restore', + 'breakpoint', + 'agent', + 'models', 'run', - 'run_new_session', + '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') - assert.truthy(cmd.name, 'Command should have a name') assert.truthy(cmd.desc, 'Command should have a description') assert.is_function(cmd.fn, 'Command should have a function') end @@ -51,75 +64,51 @@ describe('opencode.api', function() end) describe('setup', function() - it('registers all commands', function() + it('registers the main Opencode command and legacy commands', function() api.setup() - local expected_count = 0 - for _ in pairs(api.commands) do - expected_count = expected_count + 1 - end - - assert.equal(expected_count, #created_commands, 'All commands should be registered') + local main_cmd_found = false + local legacy_cmd_count = 0 for i, cmd in ipairs(created_commands) do - local found = false - for _, def in pairs(api.commands) do - if def.name == cmd.name then - found = true - assert.equal(def.desc, cmd.opts.desc, 'Command should have correct description') - break - end + if cmd.name == 'Opencode' then + main_cmd_found = true + assert.equal('Opencode.nvim main command with nested subcommands', cmd.opts.desc) + else + legacy_cmd_count = legacy_cmd_count + 1 + assert.truthy(string.match(cmd.opts.desc, 'deprecated'), 'Legacy command should be marked as deprecated') end - assert.truthy(found, 'Command ' .. cmd.name .. ' should be defined in commands table') end + + assert.truthy(main_cmd_found, 'Main Opencode command should be registered') + assert.truthy(legacy_cmd_count > 0, 'Legacy commands should be registered') end) - it('sets up command functions that call the correct core functions', function() - -- We'll use the real vim.api.nvim_create_user_command implementation to store functions + it('sets up legacy command functions that route to main command', function() local stored_fns = {} + local cmd_stub + vim.api.nvim_create_user_command = function(name, fn, _) stored_fns[name] = fn end - -- All core/ui methods are stubbed in before_each; no need for local spies or wrappers + cmd_stub = stub(vim, 'cmd') api.setup() - -- Test open_input command stored_fns['OpencodeOpenInput']() - assert.stub(core.open).was_called() - assert.stub(core.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) - - -- Test open_input_new_session command - stored_fns['OpencodeOpenInputNewSession']() - assert.stub(core.open).was_called() - assert.stub(core.open).was_called_with({ new_session = true, focus = 'input', start_insert = true }) + assert.stub(cmd_stub).was_called() + assert.stub(cmd_stub).was_called_with('Opencode open input') - -- Test stop command + cmd_stub:clear() stored_fns['OpencodeStop']() - assert.stub(core.stop).was_called() + assert.stub(cmd_stub).was_called_with('Opencode cancel') - -- Test close command + cmd_stub:clear() stored_fns['OpencodeClose']() - assert.stub(ui.close_windows).was_called() - - -- Test run command - local test_args = { args = 'test prompt' } - stored_fns['OpencodeRun'](test_args) - assert.stub(core.send_message).was_called() - assert.stub(core.send_message).was_called_with('test prompt', { - new_session = false, - focus = 'output', - }) + assert.stub(cmd_stub).was_called_with('Opencode close') - -- Test run_new_session command - test_args = { args = 'test prompt new' } - stored_fns['OpencodeRunNewSession'](test_args) - assert.stub(core.send_message).was_called() - assert.stub(core.send_message).was_called_with('test prompt new', { - new_session = true, - focus = 'output', - }) + cmd_stub:revert() end) end) @@ -152,4 +141,77 @@ describe('opencode.api', function() }) end) 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' }) + 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' }) + 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' }) + 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' }) + assert.stub(core.send_message).was_called() + assert.stub(core.send_message).was_called_with('analyze code', { + new_session = false, + focus = 'output', + agent = 'plan', + model = 'openai/gpt-4', + context = { current_file = { enabled = false } }, + }) + end) + + it('works with run_new command', function() + api.commands.run_new.fn({ 'agent=plan', 'model=openai/gpt-4', 'new', 'session', 'prompt' }) + assert.stub(core.send_message).was_called() + assert.stub(core.send_message).was_called_with('new session prompt', { + new_session = true, + focus = 'output', + agent = 'plan', + model = 'openai/gpt-4', + }) + 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() + end) + + it('Lua API accepts opts directly without parsing', function() + api.run('test prompt', { agent = 'plan', model = 'openai/gpt-4' }) + assert.stub(core.send_message).was_called() + assert.stub(core.send_message).was_called_with('test prompt', { + new_session = false, + focus = 'output', + agent = 'plan', + model = 'openai/gpt-4', + }) + end) + end) end) diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua index 19199083..20bb1c28 100644 --- a/tests/unit/keymap_spec.lua +++ b/tests/unit/keymap_spec.lua @@ -343,7 +343,7 @@ describe('opencode.keymap', function() it('falls back to API description when no custom desc provided', function() local test_keymap = { editor = { - ['test'] = { 'open_input' }, -- No custom desc + ['test'] = { 'toggle' }, }, } @@ -351,9 +351,9 @@ describe('opencode.keymap', function() assert.equal(1, #set_keymaps, 'Should set up 1 keymap') - -- The API description should be used (assuming open_input has a description in the API) local keymap_entry = set_keymaps[1] 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) @@ -423,9 +423,7 @@ describe('opencode.keymap', function() end) end) - - - describe('setup_permisson_keymap', function() + describe('setup_permission_keymap', function() it('sets up permission keymaps when there is a current permission', function() local state = require('opencode.state') state.current_permission = { id = 'test' } diff --git a/tests/unit/util_spec.lua b/tests/unit/util_spec.lua index a0449735..6e6e2ed0 100644 --- a/tests/unit/util_spec.lua +++ b/tests/unit/util_spec.lua @@ -26,3 +26,68 @@ describe('util.parse_dot_args', function() assert.are.same({}, args) end) end) + +describe('util.parse_run_args', function() + it('parses no prefixes', function() + local opts, prompt = util.parse_run_args({ 'just', 'a', 'regular', 'prompt' }) + assert.are.same({}, opts) + assert.equals('just a regular prompt', prompt) + end) + + it('parses single agent prefix', function() + local opts, prompt = util.parse_run_args({ 'agent=plan', 'hello', 'world' }) + assert.are.same({ agent = 'plan' }, opts) + assert.equals('hello world', prompt) + end) + + it('parses single model prefix', function() + local opts, prompt = util.parse_run_args({ 'model=openai/gpt-4', 'analyze', 'this' }) + assert.are.same({ model = 'openai/gpt-4' }, opts) + assert.equals('analyze this', prompt) + end) + + it('parses single context prefix', function() + local opts, prompt = util.parse_run_args({ 'context=current_file.enabled=false', 'test' }) + assert.are.same({ context = { current_file = { enabled = false } } }, opts) + assert.equals('test', prompt) + end) + + it('parses multiple prefixes in order', function() + local opts, prompt = util.parse_run_args({ 'agent=plan', 'model=openai/gpt-4', 'context=current_file.enabled=false', 'prompt', 'here' }) + assert.are.same({ + agent = 'plan', + model = 'openai/gpt-4', + context = { current_file = { enabled = false } } + }, opts) + assert.equals('prompt here', prompt) + end) + + it('parses context with multiple comma-delimited values', function() + local opts, prompt = util.parse_run_args({ 'context=current_file.enabled=false,selection.enabled=true', 'test' }) + assert.are.same({ + context = { + current_file = { enabled = false }, + selection = { enabled = true } + } + }, opts) + assert.equals('test', prompt) + end) + + it('handles empty prompt after prefixes', function() + local opts, prompt = util.parse_run_args({ 'agent=plan' }) + assert.are.same({ agent = 'plan' }, opts) + assert.equals('', prompt) + end) + + it('handles empty string', function() + local opts, prompt = util.parse_run_args({}) + assert.are.same({}, opts) + assert.equals('', prompt) + end) + + it('stops parsing at first non-prefix token', function() + local opts, prompt = util.parse_run_args({ 'agent=plan', 'some', 'prompt', 'model=openai/gpt-4' }) + assert.are.same({ agent = 'plan' }, opts) + assert.equals('some prompt model=openai/gpt-4', prompt) + end) +end)