diff --git a/README.md b/README.md index ff2936d2..a427ce5e 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 a52500d2..7515be43 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 fdacc27e..2da1b4b2 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 d521648f..d918030f 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 7e902149..6d577d25 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,14 @@ 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' }, + }) + -- 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 new file mode 100644 index 00000000..2ac7c2be --- /dev/null +++ b/tests/unit/bar_segments_spec.lua @@ -0,0 +1,99 @@ +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 + 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) + + after_each(function() + topbar.close() + if state.windows then + 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 + 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('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' }) + 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)