diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index ac627021..a55d7858 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -1,56 +1,115 @@ 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 +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 = { + build = 'OpencodeAgentBuild', + plan = 'OpencodeAgentPlan', + } + return highlights[mode] or 'OpencodeAgentCustom' +end + +local function build_left_segments() + local segments = {} + 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 - ---@cast state.windows { output_win: integer } + return segments +end +local function build_right_segments() local segments = {} - local append_to_footer = function(text) - return text and text ~= '' and table.insert(segments, text) + if state.is_running() then + local cancel_keymap = config.get_key_for_function('input_window', 'stop') or '' + table.insert(segments, { string.format('%s ', cancel_keymap), 'OpencodeInputLegend' }) + table.insert(segments, { 'to cancel', 'OpencodeHint' }) + table.insert(segments, { ' ' }) + end + + if not state.is_running() and state.current_model then + table.insert(segments, { state.current_model, 'OpencodeHint' }) + table.insert(segments, { ' ' }) end - 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) + if state.current_mode then + table.insert(segments, { + string.format(' %s ', state.current_mode:upper()), + get_mode_highlight(), + }) 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)) + return segments +end + +local function add_segments(segments, parts, highlights, col) + for _, segment in ipairs(segments) do + local text = segment[1] + table.insert(parts, text) + + if segment[2] then + table.insert(highlights, { group = segment[2], start_col = col, end_col = col + #text }) end + + col = col + #text 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) + return col +end + +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 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 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 - M.set_content({ footer_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() + if not output_window.mounted() or not M.mounted() then + return + end + ---@cast state.windows OpencodeWindowState + + 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, highlights = build_footer_from_segments(left_segments, right_segments, win_width) + + M.set_content({ footer_text }, highlights) end ---@param output_win integer @@ -80,6 +139,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 +148,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 +174,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 boolean # True if the footer is properly mounted and windows are valid 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 +216,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 } + ---@cast state.windows OpencodeWindowState - 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) + local buf = state.windows.footer_buf --[[@as integer]] + local ns_id = vim.api.nvim_create_namespace('opencode_footer') - M.set_content({}) -end + 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) -function M.set_content(lines) - if not M.mounted() then - return + 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 - ---@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 }) + vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) +end + +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..03ad456a 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -10,38 +10,32 @@ 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#' - end + local result = table.concat(parts, ' | ') + 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 +70,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 +94,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 +115,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 +125,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