From 0dddb96b914a5b28b61df8a033df0077a5e03557 Mon Sep 17 00:00:00 2001 From: disrupted Date: Sat, 31 Jan 2026 21:32:25 +0100 Subject: [PATCH 1/6] feat(event_manager): fire User autocmds for server-sent events --- README.md | 25 +++++++++++ lua/opencode/event_manager.lua | 26 ++++++++---- tests/unit/event_manager_spec.lua | 69 +++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0c80d419..d54e5444 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) @@ -865,6 +867,29 @@ 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", -- The 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 + ## 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..c1f7a85b 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -303,12 +303,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 +313,26 @@ 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) + -- Dispatch to internal listeners + 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 + + -- Fire User autocmd for external subscribers + if not vim.startswith(event_name, 'custom.') then + vim.api.nvim_exec_autocmds('User', { + pattern = 'OpencodeEvent:' .. event_name, + data = { + event = event, + }, + }) + end end --- Start the event manager and begin listening to server events diff --git a/tests/unit/event_manager_spec.lua b/tests/unit/event_manager_spec.lua index 8275018c..49f644dd 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,69 @@ 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 + + -- Set up autocmd listener + local autocmd_id = vim.api.nvim_create_autocmd('User', { + pattern = 'OpencodeEvent:test_event', + callback = function(args) + autocmd_called = true + autocmd_data = args.data + end, + }) + + -- Emit event + event_manager:emit('test_event', { test = 'value' }) + + -- Wait for autocmd to fire + vim.wait(100, function() + return autocmd_called + end) + + -- Clean up + 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 + + -- Set up autocmd listener (no internal listener subscribed) + local autocmd_id = vim.api.nvim_create_autocmd('User', { + pattern = 'OpencodeEvent:orphan_event', + callback = function(args) + autocmd_called = true + end, + }) + + -- Emit event without internal listeners + event_manager:emit('orphan_event', { data = 'test' }) + + -- Wait for autocmd to fire + vim.wait(100, function() + return autocmd_called + end) + + -- Clean up + vim.api.nvim_del_autocmd(autocmd_id) + + assert.is_true(autocmd_called) + end) + end) +end) + From a075b2c40d9ea99ab231358fcfbe4480cd4ef2cc Mon Sep 17 00:00:00 2001 From: disrupted Date: Sun, 1 Feb 2026 00:07:33 +0100 Subject: [PATCH 2/6] docs: add example for autocmd subscribe --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d54e5444..22899189 100644 --- a/README.md +++ b/README.md @@ -877,8 +877,8 @@ The autocmd receives event data in `args.data.event`: ```lua { - type = "event.name", -- The event type - properties = { ... } -- Event-specific properties + type = "event.name", -- Opencode event type + properties = { ... } -- Event-specific properties } ``` @@ -890,6 +890,19 @@ You can use wildcards to match multiple event types: - `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('permission requested', 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. From 890f323dfd429cc53bcc500b359cc7d2bae99f80 Mon Sep 17 00:00:00 2001 From: disrupted Date: Sun, 1 Feb 2026 00:10:21 +0100 Subject: [PATCH 3/6] docs: fix example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 22899189..a775c228 100644 --- a/README.md +++ b/README.md @@ -897,7 +897,7 @@ vim.api.nvim_create_autocmd('User', { pattern = 'OpencodeEvent:permission.asked', callback = function(args) local event = args.data.event - vim.notify('permission requested', vim.inspect(event)) + vim.notify(vim.inspect(event)) -- trigger custom logic end, }) From 27729d652c3fdf8996b058e92fc23800faec9b4b Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 2 Feb 2026 17:34:53 +0100 Subject: [PATCH 4/6] chore(review): delete comments --- lua/opencode/event_manager.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index c1f7a85b..5cb6cfdd 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -313,7 +313,6 @@ function EventManager:emit(event_name, data) table.insert(self.captured_events, vim.deepcopy(event)) end - -- Dispatch to internal listeners if listeners then for _, callback in ipairs(listeners) do local ok, result = util.pcall_trace(callback, data) @@ -324,7 +323,6 @@ function EventManager:emit(event_name, data) end end - -- Fire User autocmd for external subscribers if not vim.startswith(event_name, 'custom.') then vim.api.nvim_exec_autocmds('User', { pattern = 'OpencodeEvent:' .. event_name, From 3689da2f98a44694cb36d4d4ada15c2c2df45922 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 2 Feb 2026 17:47:34 +0100 Subject: [PATCH 5/6] chore(review): delete comments --- tests/unit/event_manager_spec.lua | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/unit/event_manager_spec.lua b/tests/unit/event_manager_spec.lua index 49f644dd..633a28aa 100644 --- a/tests/unit/event_manager_spec.lua +++ b/tests/unit/event_manager_spec.lua @@ -124,7 +124,6 @@ describe('EventManager', function() local autocmd_called = false local autocmd_data = nil - -- Set up autocmd listener local autocmd_id = vim.api.nvim_create_autocmd('User', { pattern = 'OpencodeEvent:test_event', callback = function(args) @@ -133,15 +132,12 @@ describe('EventManager', function() end, }) - -- Emit event event_manager:emit('test_event', { test = 'value' }) - -- Wait for autocmd to fire vim.wait(100, function() return autocmd_called end) - -- Clean up vim.api.nvim_del_autocmd(autocmd_id) assert.is_true(autocmd_called) @@ -156,7 +152,6 @@ describe('EventManager', function() it('should fire User autocmd even when no internal listeners exist', function() local autocmd_called = false - -- Set up autocmd listener (no internal listener subscribed) local autocmd_id = vim.api.nvim_create_autocmd('User', { pattern = 'OpencodeEvent:orphan_event', callback = function(args) @@ -164,19 +159,15 @@ describe('EventManager', function() end, }) - -- Emit event without internal listeners event_manager:emit('orphan_event', { data = 'test' }) - -- Wait for autocmd to fire vim.wait(100, function() return autocmd_called end) - -- Clean up vim.api.nvim_del_autocmd(autocmd_id) assert.is_true(autocmd_called) end) end) end) - From b0872b7c2239b1131c9d4c51bc7e0204839f0f9f Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 2 Feb 2026 18:04:35 +0100 Subject: [PATCH 6/6] refactor(event_manager): try removing non-serializable userdata from custom events --- lua/opencode/event_manager.lua | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index 5cb6cfdd..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 @@ -323,14 +322,12 @@ function EventManager:emit(event_name, data) end end - if not vim.startswith(event_name, 'custom.') then - vim.api.nvim_exec_autocmds('User', { - pattern = 'OpencodeEvent:' .. event_name, - data = { - event = event, - }, - }) - 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 @@ -348,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)