Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 57 additions & 55 deletions README.md

Large diffs are not rendered by default.

755 changes: 376 additions & 379 deletions lua/opencode/api.lua

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ M.defaults = {
preferred_completion = nil,
default_global_keymaps = true,
default_mode = 'build',
legacy_commands = true,
keymap_prefix = '<leader>o',
keymap = {
editor = {
Expand Down Expand Up @@ -38,7 +39,7 @@ M.defaults = {
},
output_window = {
['<esc>'] = { 'close' },
['<C-c>'] = { 'stop' },
['<C-c>'] = { 'cancel' },
[']]'] = { 'next_message' },
['[['] = { 'prev_message' },
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
Expand All @@ -51,7 +52,7 @@ M.defaults = {
input_window = {
['<cr>'] = { 'submit_input_prompt', mode = { 'n', 'i' } },
['<esc>'] = { 'close' },
['<C-c>'] = { 'stop' },
['<C-c>'] = { 'cancel' },
['~'] = { 'mention_file', mode = 'i' },
['@'] = { 'mention', mode = 'i' },
['/'] = { 'slash_commands', mode = 'i' },
Expand Down
6 changes: 3 additions & 3 deletions lua/opencode/core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lua/opencode/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
---@field snapshot string
---@field diff string

---@class SessionShareInfo
---@field url string

---@class Session
---@field workspace string
---@field description string
Expand All @@ -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
Expand Down
2 changes: 0 additions & 2 deletions lua/opencode/ui/icons.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lua/opencode/ui/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions lua/opencode/util.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name>] [model=<model>] [context=<key=value,...>] <prompt>
--- @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
170 changes: 116 additions & 54 deletions tests/unit/api_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -31,95 +31,84 @@ 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
end)
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)

Expand Down Expand Up @@ -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)
8 changes: 3 additions & 5 deletions tests/unit/keymap_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -343,17 +343,17 @@ describe('opencode.keymap', function()
it('falls back to API description when no custom desc provided', function()
local test_keymap = {
editor = {
['<leader>test'] = { 'open_input' }, -- No custom desc
['<leader>test'] = { 'toggle' },
},
}

keymap.setup(test_keymap)

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)

Expand Down Expand Up @@ -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' }
Expand Down
Loading