Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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,
},
})
```
Expand Down
2 changes: 2 additions & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 46 additions & 2 deletions lua/opencode/ui/footer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 43 additions & 8 deletions lua/opencode/ui/topbar.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/bar_segments_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
Loading