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
193 changes: 142 additions & 51 deletions lua/opencode/ui/footer.lua
Original file line number Diff line number Diff line change
@@ -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 '<C-c>'
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 '<C-c>'
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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
63 changes: 33 additions & 30 deletions lua/opencode/ui/topbar.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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