Skip to content

Commit d2447ed

Browse files
feat(hooks): add done_thinking and permission_requested hooks to user config (#125)
1 parent 27860e0 commit d2447ed

File tree

7 files changed

+174
-2
lines changed

7 files changed

+174
-2
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ require('opencode').setup({
259259
hooks = {
260260
on_file_edited = nil, -- Called after a file is edited by opencode.
261261
on_session_loaded = nil, -- Called after a session is loaded.
262+
on_done_thinking = nil, -- Called when opencode finishes thinking (all jobs complete).
263+
on_permission_requested = nil, -- Called when a permission request is issued.
262264
},
263265
})
264266
```
@@ -594,12 +596,14 @@ The plugin defines several highlight groups that can be customized to match your
594596

595597
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.
596598

597-
## 🪝Custom user hooks
599+
## 🪝 Custom user hooks
598600

599601
You can define custom functions to be called at specific events in Opencode:
600602

601603
- `on_file_edited`: Called after a file is edited by Opencode.
602604
- `on_session_loaded`: Called after a session is loaded.
605+
- `on_done_thinking`: Called when Opencode finishes thinking (all user jobs complete).
606+
- `on_permission_requested`: Called when a permission request is issued.
603607

604608
```lua
605609
require('opencode').setup({
@@ -612,6 +616,14 @@ require('opencode').setup({
612616
-- Custom logic after a session is loaded
613617
print("Session loaded: " .. session_name)
614618
end,
619+
on_done_thinking = function()
620+
-- Custom logic when thinking is done
621+
print("Done thinking!")
622+
end,
623+
on_permission_requested = function()
624+
-- Custom logic when a permission is requested
625+
print("Permission requested!")
626+
end,
615627
},
616628
})
617629
```

lua/opencode/config.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ M.defaults = {
191191
hooks = {
192192
on_file_edited = nil,
193193
on_session_loaded = nil,
194+
on_done_thinking = nil,
195+
on_permission_requested = nil,
194196
},
195197
}
196198

lua/opencode/core.lua

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,25 @@ function M.send_message(prompt, opts)
147147
params.parts = context.format_message(prompt, opts.context)
148148
M.before_run(opts)
149149

150+
-- Capture the session ID to ensure we track the message count for the correct session
151+
local session_id = state.active_session.id
152+
local sent_message_count = vim.deepcopy(state.user_message_count)
153+
sent_message_count[session_id] = (sent_message_count[session_id] or 0) + 1
154+
state.user_message_count = sent_message_count
155+
150156
state.api_client
151-
:create_message(state.active_session.id, params)
157+
:create_message(session_id, params)
152158
:and_then(function(response)
153159
if not response or not response.info or not response.parts then
154160
-- fall back to full render. incremental render is handled
155161
-- event manager
156162
ui.render_output()
157163
end
158164

165+
local received_message_count = vim.deepcopy(state.user_message_count)
166+
received_message_count[response.info.sessionID] = (received_message_count[response.info.sessionID] ~= nil) and (received_message_count[response.info.sessionID] - 1) or 0
167+
state.user_message_count = received_message_count
168+
159169
M.after_run(prompt)
160170
end)
161171
:catch(function(err)
@@ -367,6 +377,29 @@ function M.initialize_current_model()
367377
return state.current_model
368378
end
369379

380+
function M._on_user_message_count_change(_, new, old)
381+
if config.hooks and config.hooks.on_done_thinking then
382+
local all_sessions = session.get_all_workspace_sessions() or {}
383+
local done_sessions = vim.tbl_filter(function(s)
384+
local msg_count = new[s.id] or 0
385+
local old_msg_count = (old and old[s.id]) or 0
386+
return msg_count == 0 and old_msg_count > 0
387+
end, all_sessions)
388+
389+
for _, done_session in ipairs(done_sessions) do
390+
pcall(config.hooks.on_done_thinking, done_session)
391+
end
392+
end
393+
end
394+
395+
function M._on_current_permission_change(_, new, old)
396+
local permission_requested = old == nil and new ~= nil
397+
if config.hooks and config.hooks.on_permission_requested and permission_requested then
398+
local local_session = session.get_by_id(state.active_session.id) or {}
399+
pcall(config.hooks.on_permission_requested, local_session)
400+
end
401+
end
402+
370403
--- Handle clipboard image data by saving it to a file and adding it to context
371404
--- @return boolean success True if image was successfully handled
372405
function M.paste_image_from_clipboard()
@@ -375,6 +408,8 @@ end
375408

376409
function M.setup()
377410
state.subscribe('opencode_server', on_opencode_server)
411+
state.subscribe('user_message_count', M._on_user_message_count_change)
412+
state.subscribe('current_permission', M._on_current_permission_change)
378413

379414
vim.schedule(function()
380415
M.opencode_ok()

lua/opencode/server_job.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ end
2626
--- @return Promise<T> promise A promise that resolves with the result or rejects with an error
2727
function M.call_api(url, method, body)
2828
local call_promise = Promise.new()
29+
2930
state.job_count = state.job_count + 1
3031

3132
local request_entry = { nil, call_promise }

lua/opencode/state.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
---@field cost number
3333
---@field tokens_count number
3434
---@field job_count number
35+
---@field user_message_count table<string, number>
3536
---@field opencode_server OpencodeServer|nil
3637
---@field api_client OpencodeApiClient
3738
---@field event_manager EventManager|nil
@@ -80,6 +81,7 @@ local _state = {
8081
tokens_count = 0,
8182
-- job
8283
job_count = 0,
84+
user_message_count = {},
8385
opencode_server = nil,
8486
api_client = nil,
8587
event_manager = nil,

lua/opencode/types.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@
150150
---@class OpencodeHooks
151151
---@field on_file_edited? fun(file: string): nil
152152
---@field on_session_loaded? fun(session: Session): nil
153+
---@field on_done_thinking? fun(session: Session): nil
154+
---@field on_permission_requested? fun(session: Session): nil
153155

154156
---@class OpencodeProviders
155157
---@field [string] string[]

tests/unit/hooks_spec.lua

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local renderer = require('opencode.ui.renderer')
22
local config = require('opencode.config')
33
local state = require('opencode.state')
4+
local core = require('opencode.core')
45
local helpers = require('tests.helpers')
56
local ui = require('opencode.ui.ui')
67

@@ -10,6 +11,8 @@ describe('hooks', function()
1011
config.hooks = {
1112
on_file_edited = nil,
1213
on_session_loaded = nil,
14+
on_done_thinking = nil,
15+
on_permission_requested = nil,
1316
}
1417
end)
1518

@@ -20,6 +23,8 @@ describe('hooks', function()
2023
config.hooks = {
2124
on_file_edited = nil,
2225
on_session_loaded = nil,
26+
on_done_thinking = nil,
27+
on_permission_requested = nil,
2328
}
2429
end)
2530

@@ -107,4 +112,117 @@ describe('hooks', function()
107112
end)
108113
end)
109114
end)
115+
116+
describe('on_done_thinking', function()
117+
it('should call hook when thinking is done', function()
118+
local called = false
119+
local called_session = nil
120+
121+
config.hooks.on_done_thinking = function(session)
122+
called = true
123+
called_session = session
124+
end
125+
126+
-- Mock session.get_all_workspace_sessions to return our test session
127+
local session_module = require('opencode.session')
128+
local original_get_all = session_module.get_all_workspace_sessions
129+
session_module.get_all_workspace_sessions = function()
130+
return { { id = 'test-session', title = 'Test' } }
131+
end
132+
133+
state.subscribe('user_message_count', core._on_user_message_count_change)
134+
135+
-- Simulate job count change from 1 to 0 (done thinking) for a specific session
136+
state.active_session = { id = 'test-session', title = 'Test' }
137+
state.user_message_count = { ['test-session'] = 1 }
138+
state.user_message_count = { ['test-session'] = 0 }
139+
140+
-- Wait for async notification
141+
vim.wait(100, function() return called end)
142+
143+
-- Restore original function
144+
session_module.get_all_workspace_sessions = original_get_all
145+
state.unsubscribe('user_message_count', core._on_user_message_count_change)
146+
147+
assert.is_true(called)
148+
assert.are.equal(called_session.id, 'test-session')
149+
end)
150+
151+
it('should not error when hook is nil', function()
152+
config.hooks.on_done_thinking = nil
153+
state.active_session = { id = 'test-session', title = 'Test' }
154+
state.user_message_count = { ['test-session'] = 1 }
155+
assert.has_no.errors(function()
156+
state.user_message_count = { ['test-session'] = 0 }
157+
end)
158+
end)
159+
160+
it('should not crash when hook throws error', function()
161+
config.hooks.on_done_thinking = function()
162+
error('test error')
163+
end
164+
165+
state.active_session = { id = 'test-session', title = 'Test' }
166+
state.user_message_count = { ['test-session'] = 1 }
167+
assert.has_no.errors(function()
168+
state.user_message_count = { ['test-session'] = 0 }
169+
end)
170+
end)
171+
end)
172+
173+
describe('on_permission_requested', function()
174+
it('should call hook when permission is requested', function()
175+
local called = false
176+
local called_session = nil
177+
178+
config.hooks.on_permission_requested = function(session)
179+
called = true
180+
called_session = session
181+
end
182+
183+
-- Mock session.get_by_id to return our test session
184+
local session_module = require('opencode.session')
185+
local original_get_by_id = session_module.get_by_id
186+
session_module.get_by_id = function(id)
187+
return { id = id, title = 'Test' }
188+
end
189+
190+
-- Set up the subscription manually
191+
state.subscribe('current_permission', core._on_current_permission_change)
192+
193+
-- Simulate permission change from nil to a value
194+
state.active_session = { id = 'test-session', title = 'Test' }
195+
state.current_permission = nil
196+
state.current_permission = { tool = 'test_tool', action = 'read' }
197+
198+
-- Wait for async notification
199+
vim.wait(100, function() return called end)
200+
201+
-- Restore original function
202+
session_module.get_by_id = original_get_by_id
203+
state.unsubscribe('current_permission', core._on_current_permission_change)
204+
205+
assert.is_true(called)
206+
assert.are.equal(called_session.id, 'test-session')
207+
end)
208+
209+
it('should not error when hook is nil', function()
210+
config.hooks.on_permission_requested = nil
211+
state.current_permission = nil
212+
assert.has_no.errors(function()
213+
state.current_permission = { tool = 'test_tool', action = 'read' }
214+
end)
215+
end)
216+
217+
it('should not crash when hook throws error', function()
218+
config.hooks.on_permission_requested = function()
219+
error('test error')
220+
end
221+
222+
state.current_permission = nil
223+
assert.has_no.errors(function()
224+
state.current_permission = { tool = 'test_tool', action = 'read' }
225+
end)
226+
end)
227+
end)
110228
end)

0 commit comments

Comments
 (0)