From 1cc4ee1d473faaeb3a68548f39332eabbef1a86e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 6 Oct 2026 08:19:43 -0400 Subject: [PATCH 1/2] feat(hooks): add topbar and footer render hooks --- README.md | 11 +++++ lua/opencode/config.lua | 2 + lua/opencode/types.lua | 4 ++ lua/opencode/ui/footer.lua | 48 +++++++++++++++++++- lua/opencode/ui/topbar.lua | 50 ++++++++++++++++---- tests/unit/bar_segments_spec.lua | 78 ++++++++++++++++++++++++++++++++ 6 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 tests/unit/bar_segments_spec.lua diff --git a/README.md b/README.md index ff2936d2a..a427ce5e5 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,8 @@ require('opencode').setup({ on_done_thinking = nil, -- Called when a session becomes idle, including sessions started outside Neovim. on_permission_requested = nil, -- Called when a permission request is issued. on_question_asked = nil, -- Called when a question is asked. + on_topbar_render = nil, -- Transform topbar segments; return nil to clear topbar. + on_footer_render = nil, -- Transform footer segments; return nil to clear footer. }, quick_chat = { default_model = nil, -- works better with a fast model like gpt-4.1 @@ -1294,6 +1296,10 @@ You can define custom functions to be called at specific events in Opencode: - `on_done_thinking`: Called when a session becomes idle, including sessions started outside Neovim. - `on_permission_requested`: Called when a permission request is issued. - `on_question_asked`: Called when a question is asked. +- `on_topbar_render`: Receives topbar segments on each render and returns updated segments. +- `on_footer_render`: Receives footer segments on each render and returns updated segments. + +Segments use `{ 'text', 'HighlightGroup', align = 'left'|'right' }`; highlight and alignment are optional, and alignment defaults to `'left'`. Right-aligned segments render after the bar's spacer. Return `nil` to clear that bar. If a hook errors, built-in segments render unchanged. ```lua require('opencode').setup({ @@ -1320,6 +1326,11 @@ require('opencode').setup({ -- Custom logic when a question is asked print("Question asked in session " .. session.id) end, + on_footer_render = function(segments) + -- Add project context to the footer's left side. + table.insert(segments, 1, { 'repo:branch ', 'OpencodeHint' }) + return segments + end, }, }) ``` diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index a52500d22..7515be431 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -396,6 +396,8 @@ M.defaults = { on_done_thinking = nil, on_permission_requested = nil, on_question_asked = nil, + on_topbar_render = nil, + on_footer_render = nil, }, quick_chat = { default_model = nil, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index fdacc27e3..2da1b4b24 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -362,7 +362,11 @@ ---@class OpencodeCommandHookRegisterOptions ---@field command? OpencodeCommandHookScope +---@alias OpencodeBarSegment { [1]: string, [2]?: string, align?: 'left'|'right' } +---@alias OpencodeBarSegmentsHook fun(segments: OpencodeBarSegment[]): OpencodeBarSegment[]|nil ---@class OpencodeHooks +---@field on_topbar_render? OpencodeBarSegmentsHook +---@field on_footer_render? OpencodeBarSegmentsHook ---@field on_file_edited? fun(file: string): nil ---@field on_session_loaded? fun(session: OpencodeSession): nil ---@field on_done_thinking? fun(session: OpencodeSession): nil Called when a session becomes idle. diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index d521648fe..d918030f6 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -58,6 +58,39 @@ local function build_right_segments() return segments end +---@param hook OpencodeBarSegmentsHook|nil +---@param segments OpencodeBarSegment[] +---@return OpencodeBarSegment[]|nil +local function apply_segments_hook(hook, segments) + if not hook then + return segments + end + + local ok, transformed = pcall(hook, vim.deepcopy(segments)) + if not ok then + return segments + end + + return transformed +end + +---@param segments OpencodeBarSegment[] +---@return OpencodeBarSegment[], OpencodeBarSegment[] +local function split_segments(segments) + local left_segments = {} + local right_segments = {} + + for _, segment in ipairs(segments) do + if segment.align == 'right' then + table.insert(right_segments, segment) + else + table.insert(left_segments, segment) + end + end + + return left_segments, right_segments +end + local function add_segments(segments, parts, highlights, col) for _, segment in ipairs(segments) do local text = segment[1] @@ -112,8 +145,19 @@ function M.render() return end - local left_segments = build_left_segments() - local right_segments = build_right_segments() + local segments = build_left_segments() + for _, segment in ipairs(build_right_segments()) do + segment.align = 'right' + table.insert(segments, segment) + end + + local transformed_segments = apply_segments_hook(config.hooks.on_footer_render, segments) + if transformed_segments == nil then + M.set_content({}) + return + end + + local left_segments, right_segments = split_segments(transformed_segments) local win_width = vim.api.nvim_win_get_width(state.windows.output_win --[[@as integer]]) local footer_text, highlights = build_footer_from_segments(left_segments, right_segments, win_width) diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index 7e902149a..c93901f6b 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -67,13 +67,44 @@ local function format_token_info() end end - local result = table.concat(parts, ' | ') - result = result:gsub('%%', '%%%%') - return result + return table.concat(parts, ' | ') end -local function create_winbar_text(description, token_info, _) - return description .. '%=' .. token_info +---@param hook OpencodeBarSegmentsHook|nil +---@param segments OpencodeBarSegment[] +---@return OpencodeBarSegment[]|nil +local function apply_segments_hook(hook, segments) + if not hook then + return segments + end + + local ok, transformed = pcall(hook, vim.deepcopy(segments)) + if not ok then + return segments + end + + return transformed +end + +---@param segments OpencodeBarSegment[] +local function create_winbar_text(segments) + local left_parts = {} + local right_parts = {} + + for _, segment in ipairs(segments) do + local text = segment[1]:gsub('%%', '%%%%') + if segment[2] then + text = '%#' .. segment[2] .. '#' .. text .. '%*' + end + + if segment.align == 'right' then + table.insert(right_parts, text) + else + table.insert(left_parts, text) + end + end + + return table.concat(left_parts) .. '%=' .. table.concat(right_parts) end local function get_session_desc() @@ -110,10 +141,13 @@ function M.render() return end - local desc = get_session_desc():gsub('%%', '%%%%') + local desc = get_session_desc() local token_info = format_token_info() - local winbar_str = create_winbar_text(desc, token_info, vim.api.nvim_win_get_width(win)) - vim.wo[win].winbar = winbar_str + local segments = apply_segments_hook(config.hooks.on_topbar_render, { + { desc }, + { token_info, align = 'right' }, + }) + vim.wo[win].winbar = segments and create_winbar_text(segments) or '' winbar.update_highlights(win, 'OpencodeSessionDescription') end) diff --git a/tests/unit/bar_segments_spec.lua b/tests/unit/bar_segments_spec.lua new file mode 100644 index 000000000..9026e5ea7 --- /dev/null +++ b/tests/unit/bar_segments_spec.lua @@ -0,0 +1,78 @@ +local config = require('opencode.config') +local helpers = require('tests.helpers') +local state = require('opencode.state') +local store = require('opencode.state.store') +local footer = require('opencode.ui.footer') +local topbar = require('opencode.ui.topbar') +local ui = require('opencode.ui.ui') + +describe('bar segment hooks', function() + local original_state + local original_hooks + + before_each(function() + original_state = vim.deepcopy(store.state()) + original_hooks = config.hooks + config.hooks = vim.deepcopy(config.hooks) + helpers.replay_setup() + end) + + after_each(function() + topbar.close() + if state.windows then + ui.close_windows(state.windows) + end + config.hooks = original_hooks + for key, value in pairs(original_state) do + store.set_raw(key, value) + end + end) + + it('lets topbar hook prepend a styled segment', function() + config.hooks.on_topbar_render = function(segments) + table.insert(segments, 1, { 'project ', 'OpencodeHint' }) + return segments + end + topbar.render() + + assert.is_true(vim.wait(1000, function() + local winbar = vim.wo[state.windows.output_win].winbar + return winbar:find('project ', 1, true) ~= nil and winbar:find('New session', 1, true) ~= nil + end)) + end) + + it('clears topbar when hook returns nil', function() + config.hooks.on_topbar_render = function() + return nil + end + + topbar.render() + + assert.is_true(vim.wait(1000, function() + return vim.wo[state.windows.output_win].winbar == '' + end)) + end) + + it('lets footer hook prepend a left-aligned segment', function() + config.hooks.on_footer_render = function(segments) + table.insert(segments, 1, { 'repo:branch ', 'OpencodeHint' }) + return segments + end + + footer.render() + + local lines = vim.api.nvim_buf_get_lines(state.windows.footer_buf, 0, -1, false) + assert.is_true(lines[1]:find('repo:branch ', 1, true) == 1) + end) + + it('clears footer when hook returns nil', function() + config.hooks.on_footer_render = function() + return nil + end + + footer.render() + + local lines = vim.api.nvim_buf_get_lines(state.windows.footer_buf, 0, -1, false) + assert.are.same({ '' }, lines) + end) +end) From 55b72ccac5368e57ad8d52b98ac72432f51477c1 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 6 Oct 2026 13:06:01 -0400 Subject: [PATCH 2/2] fix(ui): clear local winbar when topbar hook returns nil --- lua/opencode/ui/topbar.lua | 3 ++- tests/unit/bar_segments_spec.lua | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index c93901f6b..6d577d253 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -147,7 +147,8 @@ function M.render() { desc }, { token_info, align = 'right' }, }) - vim.wo[win].winbar = segments and create_winbar_text(segments) or '' + -- An empty local winbar inherits the global value instead of clearing the bar. + vim.wo[win].winbar = segments and create_winbar_text(segments) or '%=' winbar.update_highlights(win, 'OpencodeSessionDescription') end) diff --git a/tests/unit/bar_segments_spec.lua b/tests/unit/bar_segments_spec.lua index 9026e5ea7..2ac7c2be5 100644 --- a/tests/unit/bar_segments_spec.lua +++ b/tests/unit/bar_segments_spec.lua @@ -9,10 +9,12 @@ local ui = require('opencode.ui.ui') describe('bar segment hooks', function() local original_state local original_hooks + local original_winbar before_each(function() original_state = vim.deepcopy(store.state()) original_hooks = config.hooks + original_winbar = vim.api.nvim_get_option_value('winbar', { scope = 'global' }) config.hooks = vim.deepcopy(config.hooks) helpers.replay_setup() end) @@ -23,6 +25,7 @@ describe('bar segment hooks', function() ui.close_windows(state.windows) end config.hooks = original_hooks + vim.api.nvim_set_option_value('winbar', original_winbar, { scope = 'global' }) for key, value in pairs(original_state) do store.set_raw(key, value) end @@ -49,10 +52,28 @@ describe('bar segment hooks', function() topbar.render() assert.is_true(vim.wait(1000, function() - return vim.wo[state.windows.output_win].winbar == '' + return vim.wo[state.windows.output_win].winbar == '%=' end)) end) + it('suppresses the global winbar when hook returns nil', function() + vim.api.nvim_set_option_value('winbar', 'Global winbar', { scope = 'global' }) + config.hooks.on_topbar_render = function() + return nil + end + + topbar.render() + + assert.is_true(vim.wait(1000, function() + return vim.api.nvim_get_option_value('winbar', { win = state.windows.output_win }) == '%=' + end)) + local rendered = vim.api.nvim_eval_statusline(vim.wo[state.windows.output_win].winbar, { + winid = state.windows.output_win, + use_winbar = true, + }) + assert.equals('', vim.trim(rendered.str)) + end) + it('lets footer hook prepend a left-aligned segment', function() config.hooks.on_footer_render = function(segments) table.insert(segments, 1, { 'repo:branch ', 'OpencodeHint' })