diff --git a/README.md b/README.md index 063d17c8..a865ff4e 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Refer to the [Quick Chat](#-quick-chat) section for more details. - [User Commands](#user-commands) - [Contextual Actions for Snapshots](#-contextual-actions-for-snapshots) - [Prompt Guard](#-prompt-guard) +- [Custom user hooks](#-custom-user-hooks) +- [Server-Sent Events (SSE) autocmds](#-server-sent-events-sse-autocmds) - [Quick Chat](#-quick-chat) - [Setting up opencode](#-setting-up-opencode) @@ -868,6 +870,42 @@ require('opencode').setup({ - **No parameters**: The guard function receives no parameters. Access vim state directly (e.g., `vim.fn.getcwd()`, `vim.bo.filetype`). - **Error handling**: If the guard function throws an error or returns a non-boolean value, the prompt is denied with an appropriate error message. +## 📡 Server-Sent Events (SSE) autocmds + +Opencode.nvim forwards all server-sent events as Neovim User autocmds, allowing you to react to events and automate workflows. All events are fired with the pattern `OpencodeEvent:`. + +### Event Data Structure + +The autocmd receives event data in `args.data.event`: + +```lua +{ + type = "event.name", -- Opencode event type + properties = { ... } -- Event-specific properties +} +``` + +### Wildcard Patterns + +You can use wildcards to match multiple event types: + +- `OpencodeEvent:*` - All events +- `OpencodeEvent:session.*` - All session events +- `OpencodeEvent:permission.*` - All permission events + +### Example + +```lua +vim.api.nvim_create_autocmd('User', { + pattern = 'OpencodeEvent:permission.asked', + callback = function(args) + local event = args.data.event + vim.notify(vim.inspect(event)) + -- trigger custom logic + end, +}) +``` + ## Quick chat Quick chat allows you to start a temporary opencode session with context from the current line or selection. diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index 21437d58..665b36d7 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -100,10 +100,9 @@ local util = require('opencode.util') --- @field properties {ide: string} --- @class ServerStartingEvent ---- @field server_job table +--- @field url string --- @class ServerReadyEvent ---- @field server_job table --- @field url string --- @class ServerStoppedEvent @@ -303,12 +302,9 @@ end --- Emit an event to all subscribers --- @param event_name OpencodeEventName The event name ---- @param data any Data to pass to event listeners +--- @param data table Data to pass to event listeners function EventManager:emit(event_name, data) local listeners = self.events[event_name] - if not listeners then - return - end local event = { type = event_name, properties = data } @@ -316,13 +312,22 @@ function EventManager:emit(event_name, data) table.insert(self.captured_events, vim.deepcopy(event)) end - for _, callback in ipairs(listeners) do - local ok, result = util.pcall_trace(callback, data) + if listeners then + for _, callback in ipairs(listeners) do + local ok, result = util.pcall_trace(callback, data) - if not ok then - vim.notify('Error calling ' .. event_name .. ' listener: ' .. result, vim.log.levels.ERROR) + if not ok then + vim.notify('Error calling ' .. event_name .. ' listener: ' .. result, vim.log.levels.ERROR) + end end end + + vim.api.nvim_exec_autocmds('User', { + pattern = 'OpencodeEvent:' .. event_name, + data = { + event = event, + }, + }) end --- Start the event manager and begin listening to server events @@ -340,10 +345,10 @@ function EventManager:start() --- @param prev OpencodeServer|nil function(key, current, prev) if current and current:get_spawn_promise() then - self:emit('custom.server_starting', { server_job = current }) + self:emit('custom.server_starting', { url = current.url }) current:get_spawn_promise():and_then(function(server) - self:emit('custom.server_ready', { server_job = server, url = server.url }) + self:emit('custom.server_ready', { url = server.url }) vim.defer_fn(function() self:_subscribe_to_server_events(server) end, 200) diff --git a/tests/unit/event_manager_spec.lua b/tests/unit/event_manager_spec.lua index 8275018c..633a28aa 100644 --- a/tests/unit/event_manager_spec.lua +++ b/tests/unit/event_manager_spec.lua @@ -102,10 +102,10 @@ describe('EventManager', function() it('should handle starting and stopping', function() assert.is_false(event_manager.is_started) - + event_manager:start() assert.is_true(event_manager.is_started) - + event_manager:stop() assert.is_false(event_manager.is_started) assert.are.same({}, event_manager.events) @@ -114,8 +114,60 @@ describe('EventManager', function() it('should not start multiple times', function() event_manager:start() local first_start = event_manager.is_started - + event_manager:start() -- Should not do anything assert.are.equal(first_start, event_manager.is_started) end) -end) \ No newline at end of file + + describe('User autocmd events', function() + it('should fire User autocmd when emitting events', function() + local autocmd_called = false + local autocmd_data = nil + + local autocmd_id = vim.api.nvim_create_autocmd('User', { + pattern = 'OpencodeEvent:test_event', + callback = function(args) + autocmd_called = true + autocmd_data = args.data + end, + }) + + event_manager:emit('test_event', { test = 'value' }) + + vim.wait(100, function() + return autocmd_called + end) + + vim.api.nvim_del_autocmd(autocmd_id) + + assert.is_true(autocmd_called) + assert.are.same({ + event = { + type = 'test_event', + properties = { test = 'value' }, + }, + }, autocmd_data) + end) + + it('should fire User autocmd even when no internal listeners exist', function() + local autocmd_called = false + + local autocmd_id = vim.api.nvim_create_autocmd('User', { + pattern = 'OpencodeEvent:orphan_event', + callback = function(args) + autocmd_called = true + end, + }) + + event_manager:emit('orphan_event', { data = 'test' }) + + vim.wait(100, function() + return autocmd_called + end) + + vim.api.nvim_del_autocmd(autocmd_id) + + assert.is_true(autocmd_called) + end) + end) +end)