From 01bf56f8013d321fe124dc709763bd5bf1ec2766 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 11 Nov 2025 15:44:02 -0500 Subject: [PATCH 1/4] feat(ui): move model + mode to footer ands move token count topbar This makes the panel closer to opencode tui. --- lua/opencode/ui/footer.lua | 181 ++++++++++++++++++++++++++----------- lua/opencode/ui/topbar.lua | 64 +++++++------ 2 files changed, 162 insertions(+), 83 deletions(-) diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index ac627021..4ea03020 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -1,56 +1,97 @@ local state = require('opencode.state') local config = require('opencode.config') -local util = require('opencode.util') local icons = require('opencode.ui.icons') local output_window = require('opencode.ui.output_window') local snapshot = require('opencode.snapshot') -local config_file = require('opencode.config_file') local loading_animation = require('opencode.ui.loading_animation') local M = {} -function M.render() - if not output_window.mounted() or not M.mounted() then - return - end - ---@cast state.windows { output_win: integer } +local function get_mode_highlight() + local mode = (state.current_mode or ''):lower() + local highlights = { + build = 'OpencodeAgentBuild', + plan = 'OpencodeAgentPlan', + } + return highlights[mode] or 'OpencodeAgentCustom' +end +local function build_left_segments() local segments = {} - - local append_to_footer = function(text) - return text and text ~= '' and table.insert(segments, text) + if not state.is_running() and state.current_model then + table.insert(segments, state.current_model) end + return segments +end + +local function build_right_segments() + local segments = {} if state.is_running() then - local config_mod = require('opencode.config') - local cancel_keymap = config_mod.get_key_for_function('input_window', 'stop') or '' - local legend = string.format(' %s to cancel', cancel_keymap) - append_to_footer(legend) + local cancel_keymap = config.get_key_for_function('input_window', 'stop') or '' + table.insert(segments, string.format(' %s to cancel', cancel_keymap)) end - if state.current_model then - if config.ui.display_context_size then - local provider, model = state.current_model:match('^(.-)/(.+)$') - local model_info = config_file.get_model_info(provider, model) - local limit = state.tokens_count and model_info and model_info.limit and model_info.limit.context or 0 - append_to_footer(util.format_number(state.tokens_count)) - append_to_footer(util.format_percentage(limit > 0 and state.tokens_count / limit)) - end - if config.ui.display_cost then - append_to_footer(util.format_cost(state.cost)) - end - end local restore_points = snapshot.get_restore_points() if restore_points and #restore_points > 0 then - local restore_point_text = string.format('%s %d', icons.get('restore_point'), #restore_points) - append_to_footer(restore_point_text) + table.insert(segments, string.format('%s %d', icons.get('restore_point'), #restore_points)) + end + + if state.current_mode then + table.insert(segments, string.format(' %s ', state.current_mode:upper())) + end + + return segments +end + +local function build_footer_text(left_text, right_text, win_width) + local left_len = #left_text > 0 and #left_text + 1 or 0 + local right_len = #right_text > 0 and #right_text + 1 or 0 + local padding = math.max(0, win_width - left_len - right_len) + + local parts = {} + if #left_text > 0 then + table.insert(parts, left_text) + end + table.insert(parts, string.rep(' ', padding)) + if #right_text > 0 then + table.insert(parts, right_text) + end + + return table.concat(parts, ' ') +end + +local function create_mode_highlight(left_len, right_text, padding) + if not state.current_mode then + return {} end - local win_width = vim.api.nvim_win_get_width(state.windows.output_win) - local footer_text = table.concat(segments, ' | ') .. ' ' - footer_text = string.rep(' ', win_width - #footer_text) .. footer_text + local mode_text = string.format(' %s ', state.current_mode:upper()) + local mode_start = left_len + padding + (#right_text > 0 and 1 or 0) - M.set_content({ footer_text }) + return { + { + group = get_mode_highlight(), + start_col = mode_start + #right_text - #mode_text, + end_col = mode_start + #right_text, + }, + } +end + +function M.render() + if not output_window.mounted() or not M.mounted() then + return + end + ---@cast state.windows OpencodeWindowState + + local left_text = table.concat(build_left_segments(), ' ') + local right_text = table.concat(build_right_segments(), ' ') + local win_width = vim.api.nvim_win_get_width(state.windows.output_win --[[@as integer]]) + + local footer_text = build_footer_text(left_text, right_text, win_width) + local highlights = create_mode_highlight(#left_text, right_text, win_width - #left_text - #right_text - 1) + + M.set_content({ footer_text }, highlights) end ---@param output_win integer @@ -80,6 +121,7 @@ local function on_job_count_changed(_, new, old) end end +---@param windows table Windows table to set up footer in function M.setup(windows) if not windows.output_win then return false @@ -88,13 +130,20 @@ function M.setup(windows) windows.footer_win = vim.api.nvim_open_win(windows.footer_buf, false, M._build_footer_win_config(windows.output_win)) vim.api.nvim_set_option_value('winhl', 'Normal:OpencodeHint', { win = windows.footer_win }) - -- for stats changes + -- for model changes state.subscribe('current_model', on_change) + state.subscribe('current_mode', on_change) state.subscribe('active_session', on_change) -- to show C-c message state.subscribe('job_count', on_job_count_changed) state.subscribe('restore_points', on_change) + vim.api.nvim_create_autocmd({ 'VimResized', 'WinResized' }, { + callback = function() + M.update_window(windows) + end, + }) + loading_animation.setup() end @@ -107,28 +156,38 @@ function M.close() end state.unsubscribe('current_model', on_change) + state.unsubscribe('current_mode', on_change) + state.unsubscribe('active_session', on_change) state.unsubscribe('job_count', on_job_count_changed) state.unsubscribe('restore_points', on_change) loading_animation.teardown() end +local function is_valid_state(windows) + return windows and windows.footer_win and windows.output_win and windows.footer_buf +end + +---@param windows? table Optional windows table, defaults to state.windows +---@return_cast windows OpencodeWindowState function M.mounted(windows) windows = windows or state.windows return windows - and windows.footer_win - and vim.api.nvim_win_is_valid(windows.footer_win) - and windows.output_win - and vim.api.nvim_win_is_valid(windows.output_win) - and windows.footer_buf + and is_valid_state(windows) + and vim.api.nvim_win_is_valid(windows.footer_win --[[@as integer]]) + and vim.api.nvim_win_is_valid(windows.output_win --[[@as integer]]) end +---@param windows table Windows table with footer_win and output_win function M.update_window(windows) if not M.mounted(windows) then return end - vim.api.nvim_win_set_config(windows.footer_win, M._build_footer_win_config(windows.output_win)) + vim.api.nvim_win_set_config( + windows.footer_win --[[@as integer]], + M._build_footer_win_config(windows.output_win --[[@as integer]]) + ) M.render() end @@ -139,27 +198,41 @@ function M.create_buf() return footer_buf end -function M.clear() +---@param lines string[] Content lines to set in footer +---@param highlights? table[] Optional highlight definitions +function M.set_content(lines, highlights) if not M.mounted() then return end - ---@cast state.windows { footer_buf: integer } - - local foot_ns_id = vim.api.nvim_create_namespace('opencode_footer') - vim.api.nvim_buf_clear_namespace(state.windows.footer_buf, foot_ns_id, 0, -1) + ---@cast state.windows OpencodeWindowState + + local buf = state.windows.footer_buf --[[@as integer]] + local ns_id = vim.api.nvim_create_namespace('opencode_footer') + + vim.api.nvim_set_option_value('modifiable', true, { buf = buf }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines or {}) + vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1) + + if highlights and #lines > 0 then + local line_length = #(lines[1] or '') + for _, highlight in ipairs(highlights) do + local start_col = math.max(0, math.min(highlight.start_col, line_length)) + local end_col = math.max(start_col, math.min(highlight.end_col, line_length)) + + if start_col < end_col then + vim.api.nvim_buf_set_extmark(buf, ns_id, 0, start_col, { + end_col = end_col, + hl_group = highlight.group, + }) + end + end + end - M.set_content({}) + vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) end -function M.set_content(lines) - if not M.mounted() then - return - end - ---@cast state.windows { footer_buf: integer } - - vim.api.nvim_set_option_value('modifiable', true, { buf = state.windows.footer_buf }) - vim.api.nvim_buf_set_lines(state.windows.footer_buf, 0, -1, false, lines) - vim.api.nvim_set_option_value('modifiable', false, { buf = state.windows.footer_buf }) +function M.clear() + M.set_content({}) end return M diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index 6e9a8b37..f652965c 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -10,38 +10,35 @@ local LABELS = { NEW_SESSION_TITLE = 'New session', } -local function format_model_info() +local function format_token_info() local parts = {} - if config.ui.display_model then - if state.current_model then - table.insert(parts, state.current_model) + if state.current_model then + if config.ui.display_context_size then + local provider, model = state.current_model:match('^(.-)/(.+)$') + local model_info = config_file.get_model_info(provider, model) + local limit = state.tokens_count and model_info and model_info.limit and model_info.limit.context or 0 + table.insert(parts, util.format_number(state.tokens_count)) + if limit > 0 then + table.insert(parts, util.format_percentage(state.tokens_count / limit)) + end + end + if config.ui.display_cost then + table.insert(parts, util.format_cost(state.cost)) end end - return table.concat(parts, ' ') -end - -local function format_mode_info() - return ' ' .. (state.current_mode or ''):upper() .. ' ' -end - -local function get_mode_highlight() - local mode = (state.current_mode or ''):lower() - if mode == 'build' then - return '%#OpencodeAgentBuild#' - elseif mode == 'plan' then - return '%#OpencodeAgentPlan#' - else - return '%#OpencodeAgentCustom#' + local result = table.concat(parts, ' | ') + if not result or type(result) ~= 'string' then + result = '' end + result = result:gsub('%%', '%%%%') + return result end -local function create_winbar_text(description, model_info, mode_info, win_width) +local function create_winbar_text(description, token_info, win_width) local left_content = '' - local right_content = '' - - right_content = model_info .. ' ' .. get_mode_highlight() .. mode_info .. '%*' + local right_content = token_info local desc_width = win_width - util.strdisplaywidth(left_content) - util.strdisplaywidth(right_content) @@ -76,16 +73,19 @@ local function update_winbar_highlights(win_id) end local function get_session_desc() - local session_desc = LABELS.NEW_SESSION_TITLE + local session_title = LABELS.NEW_SESSION_TITLE if state.active_session then local session = require('opencode.session').get_by_id(state.active_session.id) if session and session.title ~= '' then - session_desc = session.title + session_title = session.title end end - return session_desc + if not session_title or type(session_title) ~= 'string' then + session_title = '' + end + return session_title end function M.render() @@ -97,11 +97,13 @@ function M.render() if not win then return end - -- topbar needs to at least have a value to make sure footer is positioned correctly + vim.wo[win].winbar = ' ' - vim.wo[win].winbar = - create_winbar_text(get_session_desc(), format_model_info(), format_mode_info(), vim.api.nvim_win_get_width(win)) + 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 update_winbar_highlights(win) end) @@ -116,6 +118,8 @@ function M.setup() state.subscribe('current_model', on_change) state.subscribe('active_session', on_change) state.subscribe('is_opencode_focused', on_change) + state.subscribe('tokens_count', on_change) + state.subscribe('cost', on_change) M.render() end @@ -124,5 +128,7 @@ function M.close() state.unsubscribe('current_model', on_change) state.unsubscribe('active_session', on_change) state.unsubscribe('is_opencode_focused', on_change) + state.unsubscribe('tokens_count', on_change) + state.unsubscribe('cost', on_change) end return M From 64fcb5de5c49c3516cc633bffef9fc4366cad7c5 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 11 Nov 2025 15:56:41 -0500 Subject: [PATCH 2/4] fix(footer): wrong annotation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lua/opencode/ui/footer.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index 4ea03020..3dacddc7 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -169,7 +169,7 @@ local function is_valid_state(windows) end ---@param windows? table Optional windows table, defaults to state.windows ----@return_cast windows OpencodeWindowState +---@return boolean # True if the footer is properly mounted and windows are valid function M.mounted(windows) windows = windows or state.windows return windows From b7b87b9b4484ff2cf008693a1ab0e1837241395e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 11 Nov 2025 15:57:18 -0500 Subject: [PATCH 3/4] chore(topbar): remove uneeded empty check Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lua/opencode/ui/topbar.lua | 3 --- 1 file changed, 3 deletions(-) diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index f652965c..03ad456a 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -29,9 +29,6 @@ local function format_token_info() end local result = table.concat(parts, ' | ') - if not result or type(result) ~= 'string' then - result = '' - end result = result:gsub('%%', '%%%%') return result end From df8f11671b601e3bddc522e0cc0fceec4656fc7c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 12 Nov 2025 09:36:07 -0500 Subject: [PATCH 4/4] feat(footer): move the current model to the right --- lua/opencode/ui/footer.lua | 90 +++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index 3dacddc7..a55d7858 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -7,6 +7,10 @@ local loading_animation = require('opencode.ui.loading_animation') local M = {} +local function utf8_len(str) + return vim.fn.strchars(str) +end + local function get_mode_highlight() local mode = (state.current_mode or ''):lower() local highlights = { @@ -18,8 +22,12 @@ end local function build_left_segments() local segments = {} - if not state.is_running() and state.current_model then - table.insert(segments, state.current_model) + local restore_points = snapshot.get_restore_points() + if restore_points and #restore_points > 0 then + table.insert(segments, { + string.format('%s %d', icons.get('restore_point'), #restore_points), + 'OpencodeHint', + }) end return segments end @@ -29,53 +37,64 @@ local function build_right_segments() if state.is_running() then local cancel_keymap = config.get_key_for_function('input_window', 'stop') or '' - table.insert(segments, string.format(' %s to cancel', cancel_keymap)) + table.insert(segments, { string.format('%s ', cancel_keymap), 'OpencodeInputLegend' }) + table.insert(segments, { 'to cancel', 'OpencodeHint' }) + table.insert(segments, { ' ' }) end - local restore_points = snapshot.get_restore_points() - if restore_points and #restore_points > 0 then - table.insert(segments, string.format('%s %d', icons.get('restore_point'), #restore_points)) + if not state.is_running() and state.current_model then + table.insert(segments, { state.current_model, 'OpencodeHint' }) + table.insert(segments, { ' ' }) end if state.current_mode then - table.insert(segments, string.format(' %s ', state.current_mode:upper())) + table.insert(segments, { + string.format(' %s ', state.current_mode:upper()), + get_mode_highlight(), + }) end return segments end -local function build_footer_text(left_text, right_text, win_width) - local left_len = #left_text > 0 and #left_text + 1 or 0 - local right_len = #right_text > 0 and #right_text + 1 or 0 - local padding = math.max(0, win_width - left_len - right_len) +local function add_segments(segments, parts, highlights, col) + for _, segment in ipairs(segments) do + local text = segment[1] + table.insert(parts, text) - local parts = {} - if #left_text > 0 then - table.insert(parts, left_text) - end - table.insert(parts, string.rep(' ', padding)) - if #right_text > 0 then - table.insert(parts, right_text) - end + if segment[2] then + table.insert(highlights, { group = segment[2], start_col = col, end_col = col + #text }) + end - return table.concat(parts, ' ') + col = col + #text + end + return col end -local function create_mode_highlight(left_len, right_text, padding) - if not state.current_mode then - return {} +local function build_footer_from_segments(left_segments, right_segments, win_width) + local footer_parts = {} + local highlights = {} + local current_col = 0 + + current_col = add_segments(left_segments, footer_parts, highlights, current_col) + + local left_text = table.concat(footer_parts, '') + local left_len = utf8_len(left_text) + + local right_len = 0 + for _, segment in ipairs(right_segments) do + right_len = right_len + utf8_len(segment[1]) end - local mode_text = string.format(' %s ', state.current_mode:upper()) - local mode_start = left_len + padding + (#right_text > 0 and 1 or 0) + local padding_len = math.max(0, win_width - left_len - right_len) + local padding = string.rep(' ', padding_len) + table.insert(footer_parts, padding) + current_col = current_col + padding_len - return { - { - group = get_mode_highlight(), - start_col = mode_start + #right_text - #mode_text, - end_col = mode_start + #right_text, - }, - } + current_col = add_segments(right_segments, footer_parts, highlights, current_col) + + local footer_text = table.concat(footer_parts, '') + return footer_text, highlights end function M.render() @@ -84,12 +103,11 @@ function M.render() end ---@cast state.windows OpencodeWindowState - local left_text = table.concat(build_left_segments(), ' ') - local right_text = table.concat(build_right_segments(), ' ') + local left_segments = build_left_segments() + local right_segments = build_right_segments() local win_width = vim.api.nvim_win_get_width(state.windows.output_win --[[@as integer]]) - local footer_text = build_footer_text(left_text, right_text, win_width) - local highlights = create_mode_highlight(#left_text, right_text, win_width - #left_text - #right_text - 1) + local footer_text, highlights = build_footer_from_segments(left_segments, right_segments, win_width) M.set_content({ footer_text }, highlights) end