From d87f04e5380770a9592ab142ce94abde53b898f8 Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Sat, 8 Nov 2025 18:13:07 +0100 Subject: [PATCH 1/7] feat(server_job): Add user_message_count variable to state This is meant to track the number of user initiated messages, the job_count available currently only counts the total job counts --- lua/opencode/server_job.lua | 11 +++++++++++ lua/opencode/state.lua | 2 ++ 2 files changed, 13 insertions(+) diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index 35a0a086..744c7227 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -26,7 +26,15 @@ end --- @return Promise promise A promise that resolves with the result or rejects with an error function M.call_api(url, method, body) local call_promise = Promise.new() + + local function is_user_message() + return method == 'POST' and url:match("/message$") ~= nil + end + state.job_count = state.job_count + 1 + if is_user_message() then + state.user_message_count = state.user_message_count + 1 + end local request_entry = { nil, call_promise } table.insert(M.requests, request_entry) @@ -35,6 +43,9 @@ function M.call_api(url, method, body) local function remove_from_requests() for i, entry in ipairs(M.requests) do if entry == request_entry then + if is_user_message() then + state.user_message_count = state.user_message_count - 1 + end table.remove(M.requests, i) break end diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 2263f5cc..27e62188 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -32,6 +32,7 @@ ---@field cost number ---@field tokens_count number ---@field job_count number +---@field user_message_count number ---@field opencode_server OpencodeServer|nil ---@field api_client OpencodeApiClient ---@field event_manager EventManager|nil @@ -80,6 +81,7 @@ local _state = { tokens_count = 0, -- job job_count = 0, + user_message_count = 0, opencode_server = nil, api_client = nil, event_manager = nil, From 7542cfd94197cb24cb4e29c0de6e253a7da86eaa Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Sat, 8 Nov 2025 18:16:00 +0100 Subject: [PATCH 2/7] feat(hooks): Add done_thinking and permission_requested hooks to the user config --- lua/opencode/config.lua | 2 ++ lua/opencode/core.lua | 16 ++++++++++++++++ lua/opencode/types.lua | 2 ++ 3 files changed, 20 insertions(+) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 8f2aa9bf..26cf3df4 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -189,6 +189,8 @@ M.defaults = { hooks = { on_file_edited = nil, on_session_loaded = nil, + on_done_thinking = nil, + on_permission_requested = nil, }, } diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 749893cc..c6ed899e 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -366,8 +366,24 @@ function M.initialize_current_model() return state.current_model end +local function on_job_count_change(_, new, old) + local done_thinking = new == 0 and old > 0 + if config.hooks and config.hooks.on_done_thinking and done_thinking then + pcall(config.hooks.on_done_thinking) + end +end + +local function on_current_permission_change(_, new, old) + local permission_requested = old == nil and new ~= nil + if config.hooks and config.hooks.on_permission_requested and permission_requested then + pcall(config.hooks.on_permission_requested) + end +end + function M.setup() state.subscribe('opencode_server', on_opencode_server) + state.subscribe('user_message_count', on_job_count_change) + state.subscribe('current_permission', on_current_permission_change) vim.schedule(function() M.opencode_ok() diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 4b9d0bc1..0fa303d2 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -139,6 +139,8 @@ ---@class OpencodeHooks ---@field on_file_edited? fun(file: string): nil ---@field on_session_loaded? fun(session: Session): nil +---@field on_done_thinking? fun(): nil +---@field on_permission_requested? fun(): nil ---@class OpencodeProviders ---@field [string] string[] From 722ceffe689f086fd6d602e860a815c156cbf9d8 Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Wed, 19 Nov 2025 22:09:25 +0100 Subject: [PATCH 3/7] feat: Count the user messages in core.send_message rather than with the jobs --- lua/opencode/core.lua | 6 ++++-- lua/opencode/server_job.lua | 10 ---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index c6ed899e..551238d7 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -146,6 +146,7 @@ function M.send_message(prompt, opts) params.parts = context.format_message(prompt, opts.context) M.before_run(opts) + state.user_message_count = state.user_message_count + 1 state.api_client :create_message(state.active_session.id, params) :and_then(function(response) @@ -154,6 +155,7 @@ function M.send_message(prompt, opts) -- event manager ui.render_output() end + state.user_message_count = state.user_message_count - 1 M.after_run(prompt) end) @@ -366,7 +368,7 @@ function M.initialize_current_model() return state.current_model end -local function on_job_count_change(_, new, old) +local function on_user_message_count_change(_, new, old) local done_thinking = new == 0 and old > 0 if config.hooks and config.hooks.on_done_thinking and done_thinking then pcall(config.hooks.on_done_thinking) @@ -382,7 +384,7 @@ end function M.setup() state.subscribe('opencode_server', on_opencode_server) - state.subscribe('user_message_count', on_job_count_change) + state.subscribe('user_message_count', on_user_message_count_change) state.subscribe('current_permission', on_current_permission_change) vim.schedule(function() diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index 744c7227..fa922c22 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -27,14 +27,7 @@ end function M.call_api(url, method, body) local call_promise = Promise.new() - local function is_user_message() - return method == 'POST' and url:match("/message$") ~= nil - end - state.job_count = state.job_count + 1 - if is_user_message() then - state.user_message_count = state.user_message_count + 1 - end local request_entry = { nil, call_promise } table.insert(M.requests, request_entry) @@ -43,9 +36,6 @@ function M.call_api(url, method, body) local function remove_from_requests() for i, entry in ipairs(M.requests) do if entry == request_entry then - if is_user_message() then - state.user_message_count = state.user_message_count - 1 - end table.remove(M.requests, i) break end From cd5edbdc0ef3b1f419fe67e80baa0f4e8c791e2f Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Wed, 19 Nov 2025 22:17:34 +0100 Subject: [PATCH 4/7] docs(hooks): Add on_done_thinking and on_permission_requested docs --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0616eb64..fc10a1c3 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,8 @@ require('opencode').setup({ hooks = { on_file_edited = nil, -- Called after a file is edited by opencode. on_session_loaded = nil, -- Called after a session is loaded. + on_done_thinking = nil, -- Called when opencode finishes thinking (all jobs complete). + on_permission_requested = nil, -- Called when a permission request is issued. }, }) ``` @@ -591,12 +593,14 @@ The plugin defines several highlight groups that can be customized to match your The `prompt_guard` configuration option allows you to control when prompts can be sent to Opencode. This is useful for preventing accidental or unauthorized AI interactions in certain contexts. -## 🪝Custom user hooks +## 🪝 Custom user hooks You can define custom functions to be called at specific events in Opencode: - `on_file_edited`: Called after a file is edited by Opencode. - `on_session_loaded`: Called after a session is loaded. +- `on_done_thinking`: Called when Opencode finishes thinking (all user jobs complete). +- `on_permission_requested`: Called when a permission request is issued. ```lua require('opencode').setup({ @@ -609,6 +613,14 @@ require('opencode').setup({ -- Custom logic after a session is loaded print("Session loaded: " .. session_name) end, + on_done_thinking = function() + -- Custom logic when thinking is done + print("Done thinking!") + end, + on_permission_requested = function() + -- Custom logic when a permission is requested + print("Permission requested!") + end, }, }) ``` From 591810fd7d02b22c388d5ff84762eab58521af5c Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Wed, 19 Nov 2025 22:19:31 +0100 Subject: [PATCH 5/7] test(hooks): Test on_done_thinking and on_permission_requested hooks --- tests/unit/hooks_spec.lua | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index 3b41d7b3..11b0e014 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -10,6 +10,8 @@ describe('hooks', function() config.hooks = { on_file_edited = nil, on_session_loaded = nil, + on_done_thinking = nil, + on_permission_requested = nil, } end) @@ -20,6 +22,8 @@ describe('hooks', function() config.hooks = { on_file_edited = nil, on_session_loaded = nil, + on_done_thinking = nil, + on_permission_requested = nil, } end) @@ -107,4 +111,74 @@ describe('hooks', function() end) end) end) + + describe('on_done_thinking', function() + it('should call hook when thinking is done', function() + local called = false + + config.hooks.on_done_thinking = function() + called = true + end + + -- Simulate job count change from 1 to 0 (done thinking) + state.user_message_count = 1 + state.user_message_count = 0 + + assert.is_true(called) + end) + + it('should not error when hook is nil', function() + config.hooks.on_done_thinking = nil + state.user_message_count = 1 + assert.has_no.errors(function() + state.user_message_count = 0 + end) + end) + + it('should not crash when hook throws error', function() + config.hooks.on_done_thinking = function() + error('test error') + end + + state.user_message_count = 1 + assert.has_no.errors(function() + state.user_message_count = 0 + end) + end) + end) + + describe('on_permission_requested', function() + it('should call hook when permission is requested', function() + local called = false + + config.hooks.on_permission_requested = function() + called = true + end + + -- Simulate permission change from nil to a value + state.current_permission = nil + state.current_permission = { tool = 'test_tool', action = 'read' } + + assert.is_true(called) + end) + + it('should not error when hook is nil', function() + config.hooks.on_permission_requested = nil + state.current_permission = nil + assert.has_no.errors(function() + state.current_permission = { tool = 'test_tool', action = 'read' } + end) + end) + + it('should not crash when hook throws error', function() + config.hooks.on_permission_requested = function() + error('test error') + end + + state.current_permission = nil + assert.has_no.errors(function() + state.current_permission = { tool = 'test_tool', action = 'read' } + end) + end) + end) end) From 67d7b8a3e9fe1c35345cbb2c962f79cccbca2df8 Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Thu, 20 Nov 2025 07:54:45 +0100 Subject: [PATCH 6/7] feat(core): Add session param to on_done_thinking and on_permission_requested hooks --- lua/opencode/core.lua | 4 ++-- lua/opencode/types.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 551238d7..51b2aedb 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -371,14 +371,14 @@ end local function on_user_message_count_change(_, new, old) local done_thinking = new == 0 and old > 0 if config.hooks and config.hooks.on_done_thinking and done_thinking then - pcall(config.hooks.on_done_thinking) + pcall(config.hooks.on_done_thinking, state.active_session) end end local function on_current_permission_change(_, new, old) local permission_requested = old == nil and new ~= nil if config.hooks and config.hooks.on_permission_requested and permission_requested then - pcall(config.hooks.on_permission_requested) + pcall(config.hooks.on_permission_requested, state.active_session) end end diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 0fa303d2..e22e4f62 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -139,8 +139,8 @@ ---@class OpencodeHooks ---@field on_file_edited? fun(file: string): nil ---@field on_session_loaded? fun(session: Session): nil ----@field on_done_thinking? fun(): nil ----@field on_permission_requested? fun(): nil +---@field on_done_thinking? fun(session: Session): nil +---@field on_permission_requested? fun(session: Session): nil ---@class OpencodeProviders ---@field [string] string[] From a53dad976eb2021d81cd68657957c076736cd2e9 Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Sun, 23 Nov 2025 16:21:57 +0100 Subject: [PATCH 7/7] feat: Use per sessions user messages --- lua/opencode/core.lua | 39 +++++++++++++++++------- lua/opencode/state.lua | 4 +-- tests/unit/hooks_spec.lua | 62 +++++++++++++++++++++++++++++++++------ 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index f64f931e..34ea8c25 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -147,16 +147,24 @@ function M.send_message(prompt, opts) params.parts = context.format_message(prompt, opts.context) M.before_run(opts) - state.user_message_count = state.user_message_count + 1 + -- Capture the session ID to ensure we track the message count for the correct session + local session_id = state.active_session.id + local sent_message_count = vim.deepcopy(state.user_message_count) + sent_message_count[session_id] = (sent_message_count[session_id] or 0) + 1 + state.user_message_count = sent_message_count + state.api_client - :create_message(state.active_session.id, params) + :create_message(session_id, params) :and_then(function(response) if not response or not response.info or not response.parts then -- fall back to full render. incremental render is handled -- event manager ui.render_output() end - state.user_message_count = state.user_message_count - 1 + + local received_message_count = vim.deepcopy(state.user_message_count) + received_message_count[response.info.sessionID] = (received_message_count[response.info.sessionID] ~= nil) and (received_message_count[response.info.sessionID] - 1) or 0 + state.user_message_count = received_message_count M.after_run(prompt) end) @@ -369,17 +377,26 @@ function M.initialize_current_model() return state.current_model end -local function on_user_message_count_change(_, new, old) - local done_thinking = new == 0 and old > 0 - if config.hooks and config.hooks.on_done_thinking and done_thinking then - pcall(config.hooks.on_done_thinking, state.active_session) +function M._on_user_message_count_change(_, new, old) + if config.hooks and config.hooks.on_done_thinking then + local all_sessions = session.get_all_workspace_sessions() or {} + local done_sessions = vim.tbl_filter(function(s) + local msg_count = new[s.id] or 0 + local old_msg_count = (old and old[s.id]) or 0 + return msg_count == 0 and old_msg_count > 0 + end, all_sessions) + + for _, done_session in ipairs(done_sessions) do + pcall(config.hooks.on_done_thinking, done_session) + end end end -local function on_current_permission_change(_, new, old) +function M._on_current_permission_change(_, new, old) local permission_requested = old == nil and new ~= nil if config.hooks and config.hooks.on_permission_requested and permission_requested then - pcall(config.hooks.on_permission_requested, state.active_session) + local local_session = session.get_by_id(state.active_session.id) or {} + pcall(config.hooks.on_permission_requested, local_session) end end @@ -391,8 +408,8 @@ end function M.setup() state.subscribe('opencode_server', on_opencode_server) - state.subscribe('user_message_count', on_user_message_count_change) - state.subscribe('current_permission', on_current_permission_change) + state.subscribe('user_message_count', M._on_user_message_count_change) + state.subscribe('current_permission', M._on_current_permission_change) vim.schedule(function() M.opencode_ok() diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 27e62188..8d571cb8 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -32,7 +32,7 @@ ---@field cost number ---@field tokens_count number ---@field job_count number ----@field user_message_count number +---@field user_message_count table ---@field opencode_server OpencodeServer|nil ---@field api_client OpencodeApiClient ---@field event_manager EventManager|nil @@ -81,7 +81,7 @@ local _state = { tokens_count = 0, -- job job_count = 0, - user_message_count = 0, + user_message_count = {}, opencode_server = nil, api_client = nil, event_manager = nil, diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index 11b0e014..4c8b1eb6 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -1,6 +1,7 @@ local renderer = require('opencode.ui.renderer') local config = require('opencode.config') local state = require('opencode.state') +local core = require('opencode.core') local helpers = require('tests.helpers') local ui = require('opencode.ui.ui') @@ -115,23 +116,44 @@ describe('hooks', function() describe('on_done_thinking', function() it('should call hook when thinking is done', function() local called = false + local called_session = nil - config.hooks.on_done_thinking = function() + config.hooks.on_done_thinking = function(session) called = true + called_session = session + end + + -- Mock session.get_all_workspace_sessions to return our test session + local session_module = require('opencode.session') + local original_get_all = session_module.get_all_workspace_sessions + session_module.get_all_workspace_sessions = function() + return { { id = 'test-session', title = 'Test' } } end - -- Simulate job count change from 1 to 0 (done thinking) - state.user_message_count = 1 - state.user_message_count = 0 + state.subscribe('user_message_count', core._on_user_message_count_change) + + -- Simulate job count change from 1 to 0 (done thinking) for a specific session + state.active_session = { id = 'test-session', title = 'Test' } + state.user_message_count = { ['test-session'] = 1 } + state.user_message_count = { ['test-session'] = 0 } + + -- Wait for async notification + vim.wait(100, function() return called end) + + -- Restore original function + session_module.get_all_workspace_sessions = original_get_all + state.unsubscribe('user_message_count', core._on_user_message_count_change) assert.is_true(called) + assert.are.equal(called_session.id, 'test-session') end) it('should not error when hook is nil', function() config.hooks.on_done_thinking = nil - state.user_message_count = 1 + state.active_session = { id = 'test-session', title = 'Test' } + state.user_message_count = { ['test-session'] = 1 } assert.has_no.errors(function() - state.user_message_count = 0 + state.user_message_count = { ['test-session'] = 0 } end) end) @@ -140,9 +162,10 @@ describe('hooks', function() error('test error') end - state.user_message_count = 1 + state.active_session = { id = 'test-session', title = 'Test' } + state.user_message_count = { ['test-session'] = 1 } assert.has_no.errors(function() - state.user_message_count = 0 + state.user_message_count = { ['test-session'] = 0 } end) end) end) @@ -150,16 +173,37 @@ describe('hooks', function() describe('on_permission_requested', function() it('should call hook when permission is requested', function() local called = false + local called_session = nil - config.hooks.on_permission_requested = function() + config.hooks.on_permission_requested = function(session) called = true + called_session = session + end + + -- Mock session.get_by_id to return our test session + local session_module = require('opencode.session') + local original_get_by_id = session_module.get_by_id + session_module.get_by_id = function(id) + return { id = id, title = 'Test' } end + -- Set up the subscription manually + state.subscribe('current_permission', core._on_current_permission_change) + -- Simulate permission change from nil to a value + state.active_session = { id = 'test-session', title = 'Test' } state.current_permission = nil state.current_permission = { tool = 'test_tool', action = 'read' } + -- Wait for async notification + vim.wait(100, function() return called end) + + -- Restore original function + session_module.get_by_id = original_get_by_id + state.unsubscribe('current_permission', core._on_current_permission_change) + assert.is_true(called) + assert.are.equal(called_session.id, 'test-session') end) it('should not error when hook is nil', function()