Skip to content

Commit 01bf56f

Browse files
committed
feat(ui): move model + mode to footer ands move token count topbar
This makes the panel closer to opencode tui.
1 parent 835acb0 commit 01bf56f

File tree

2 files changed

+162
-83
lines changed

2 files changed

+162
-83
lines changed

lua/opencode/ui/footer.lua

Lines changed: 127 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,97 @@
11
local state = require('opencode.state')
22
local config = require('opencode.config')
3-
local util = require('opencode.util')
43
local icons = require('opencode.ui.icons')
54
local output_window = require('opencode.ui.output_window')
65
local snapshot = require('opencode.snapshot')
7-
local config_file = require('opencode.config_file')
86
local loading_animation = require('opencode.ui.loading_animation')
97

108
local M = {}
119

12-
function M.render()
13-
if not output_window.mounted() or not M.mounted() then
14-
return
15-
end
16-
---@cast state.windows { output_win: integer }
10+
local function get_mode_highlight()
11+
local mode = (state.current_mode or ''):lower()
12+
local highlights = {
13+
build = 'OpencodeAgentBuild',
14+
plan = 'OpencodeAgentPlan',
15+
}
16+
return highlights[mode] or 'OpencodeAgentCustom'
17+
end
1718

19+
local function build_left_segments()
1820
local segments = {}
19-
20-
local append_to_footer = function(text)
21-
return text and text ~= '' and table.insert(segments, text)
21+
if not state.is_running() and state.current_model then
22+
table.insert(segments, state.current_model)
2223
end
24+
return segments
25+
end
26+
27+
local function build_right_segments()
28+
local segments = {}
2329

2430
if state.is_running() then
25-
local config_mod = require('opencode.config')
26-
local cancel_keymap = config_mod.get_key_for_function('input_window', 'stop') or '<C-c>'
27-
local legend = string.format(' %s to cancel', cancel_keymap)
28-
append_to_footer(legend)
31+
local cancel_keymap = config.get_key_for_function('input_window', 'stop') or '<C-c>'
32+
table.insert(segments, string.format(' %s to cancel', cancel_keymap))
2933
end
3034

31-
if state.current_model then
32-
if config.ui.display_context_size then
33-
local provider, model = state.current_model:match('^(.-)/(.+)$')
34-
local model_info = config_file.get_model_info(provider, model)
35-
local limit = state.tokens_count and model_info and model_info.limit and model_info.limit.context or 0
36-
append_to_footer(util.format_number(state.tokens_count))
37-
append_to_footer(util.format_percentage(limit > 0 and state.tokens_count / limit))
38-
end
39-
if config.ui.display_cost then
40-
append_to_footer(util.format_cost(state.cost))
41-
end
42-
end
4335
local restore_points = snapshot.get_restore_points()
4436
if restore_points and #restore_points > 0 then
45-
local restore_point_text = string.format('%s %d', icons.get('restore_point'), #restore_points)
46-
append_to_footer(restore_point_text)
37+
table.insert(segments, string.format('%s %d', icons.get('restore_point'), #restore_points))
38+
end
39+
40+
if state.current_mode then
41+
table.insert(segments, string.format(' %s ', state.current_mode:upper()))
42+
end
43+
44+
return segments
45+
end
46+
47+
local function build_footer_text(left_text, right_text, win_width)
48+
local left_len = #left_text > 0 and #left_text + 1 or 0
49+
local right_len = #right_text > 0 and #right_text + 1 or 0
50+
local padding = math.max(0, win_width - left_len - right_len)
51+
52+
local parts = {}
53+
if #left_text > 0 then
54+
table.insert(parts, left_text)
55+
end
56+
table.insert(parts, string.rep(' ', padding))
57+
if #right_text > 0 then
58+
table.insert(parts, right_text)
59+
end
60+
61+
return table.concat(parts, ' ')
62+
end
63+
64+
local function create_mode_highlight(left_len, right_text, padding)
65+
if not state.current_mode then
66+
return {}
4767
end
4868

49-
local win_width = vim.api.nvim_win_get_width(state.windows.output_win)
50-
local footer_text = table.concat(segments, ' | ') .. ' '
51-
footer_text = string.rep(' ', win_width - #footer_text) .. footer_text
69+
local mode_text = string.format(' %s ', state.current_mode:upper())
70+
local mode_start = left_len + padding + (#right_text > 0 and 1 or 0)
5271

53-
M.set_content({ footer_text })
72+
return {
73+
{
74+
group = get_mode_highlight(),
75+
start_col = mode_start + #right_text - #mode_text,
76+
end_col = mode_start + #right_text,
77+
},
78+
}
79+
end
80+
81+
function M.render()
82+
if not output_window.mounted() or not M.mounted() then
83+
return
84+
end
85+
---@cast state.windows OpencodeWindowState
86+
87+
local left_text = table.concat(build_left_segments(), ' ')
88+
local right_text = table.concat(build_right_segments(), ' ')
89+
local win_width = vim.api.nvim_win_get_width(state.windows.output_win --[[@as integer]])
90+
91+
local footer_text = build_footer_text(left_text, right_text, win_width)
92+
local highlights = create_mode_highlight(#left_text, right_text, win_width - #left_text - #right_text - 1)
93+
94+
M.set_content({ footer_text }, highlights)
5495
end
5596

5697
---@param output_win integer
@@ -80,6 +121,7 @@ local function on_job_count_changed(_, new, old)
80121
end
81122
end
82123

124+
---@param windows table Windows table to set up footer in
83125
function M.setup(windows)
84126
if not windows.output_win then
85127
return false
@@ -88,13 +130,20 @@ function M.setup(windows)
88130
windows.footer_win = vim.api.nvim_open_win(windows.footer_buf, false, M._build_footer_win_config(windows.output_win))
89131
vim.api.nvim_set_option_value('winhl', 'Normal:OpencodeHint', { win = windows.footer_win })
90132

91-
-- for stats changes
133+
-- for model changes
92134
state.subscribe('current_model', on_change)
135+
state.subscribe('current_mode', on_change)
93136
state.subscribe('active_session', on_change)
94137
-- to show C-c message
95138
state.subscribe('job_count', on_job_count_changed)
96139
state.subscribe('restore_points', on_change)
97140

141+
vim.api.nvim_create_autocmd({ 'VimResized', 'WinResized' }, {
142+
callback = function()
143+
M.update_window(windows)
144+
end,
145+
})
146+
98147
loading_animation.setup()
99148
end
100149

@@ -107,28 +156,38 @@ function M.close()
107156
end
108157

109158
state.unsubscribe('current_model', on_change)
159+
state.unsubscribe('current_mode', on_change)
160+
state.unsubscribe('active_session', on_change)
110161
state.unsubscribe('job_count', on_job_count_changed)
111162
state.unsubscribe('restore_points', on_change)
112163

113164
loading_animation.teardown()
114165
end
115166

167+
local function is_valid_state(windows)
168+
return windows and windows.footer_win and windows.output_win and windows.footer_buf
169+
end
170+
171+
---@param windows? table Optional windows table, defaults to state.windows
172+
---@return_cast windows OpencodeWindowState
116173
function M.mounted(windows)
117174
windows = windows or state.windows
118175
return windows
119-
and windows.footer_win
120-
and vim.api.nvim_win_is_valid(windows.footer_win)
121-
and windows.output_win
122-
and vim.api.nvim_win_is_valid(windows.output_win)
123-
and windows.footer_buf
176+
and is_valid_state(windows)
177+
and vim.api.nvim_win_is_valid(windows.footer_win --[[@as integer]])
178+
and vim.api.nvim_win_is_valid(windows.output_win --[[@as integer]])
124179
end
125180

181+
---@param windows table Windows table with footer_win and output_win
126182
function M.update_window(windows)
127183
if not M.mounted(windows) then
128184
return
129185
end
130186

131-
vim.api.nvim_win_set_config(windows.footer_win, M._build_footer_win_config(windows.output_win))
187+
vim.api.nvim_win_set_config(
188+
windows.footer_win --[[@as integer]],
189+
M._build_footer_win_config(windows.output_win --[[@as integer]])
190+
)
132191
M.render()
133192
end
134193

@@ -139,27 +198,41 @@ function M.create_buf()
139198
return footer_buf
140199
end
141200

142-
function M.clear()
201+
---@param lines string[] Content lines to set in footer
202+
---@param highlights? table[] Optional highlight definitions
203+
function M.set_content(lines, highlights)
143204
if not M.mounted() then
144205
return
145206
end
146-
---@cast state.windows { footer_buf: integer }
147-
148-
local foot_ns_id = vim.api.nvim_create_namespace('opencode_footer')
149-
vim.api.nvim_buf_clear_namespace(state.windows.footer_buf, foot_ns_id, 0, -1)
207+
---@cast state.windows OpencodeWindowState
208+
209+
local buf = state.windows.footer_buf --[[@as integer]]
210+
local ns_id = vim.api.nvim_create_namespace('opencode_footer')
211+
212+
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
213+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines or {})
214+
vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1)
215+
216+
if highlights and #lines > 0 then
217+
local line_length = #(lines[1] or '')
218+
for _, highlight in ipairs(highlights) do
219+
local start_col = math.max(0, math.min(highlight.start_col, line_length))
220+
local end_col = math.max(start_col, math.min(highlight.end_col, line_length))
221+
222+
if start_col < end_col then
223+
vim.api.nvim_buf_set_extmark(buf, ns_id, 0, start_col, {
224+
end_col = end_col,
225+
hl_group = highlight.group,
226+
})
227+
end
228+
end
229+
end
150230

151-
M.set_content({})
231+
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
152232
end
153233

154-
function M.set_content(lines)
155-
if not M.mounted() then
156-
return
157-
end
158-
---@cast state.windows { footer_buf: integer }
159-
160-
vim.api.nvim_set_option_value('modifiable', true, { buf = state.windows.footer_buf })
161-
vim.api.nvim_buf_set_lines(state.windows.footer_buf, 0, -1, false, lines)
162-
vim.api.nvim_set_option_value('modifiable', false, { buf = state.windows.footer_buf })
234+
function M.clear()
235+
M.set_content({})
163236
end
164237

165238
return M

lua/opencode/ui/topbar.lua

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,38 +10,35 @@ local LABELS = {
1010
NEW_SESSION_TITLE = 'New session',
1111
}
1212

13-
local function format_model_info()
13+
local function format_token_info()
1414
local parts = {}
1515

16-
if config.ui.display_model then
17-
if state.current_model then
18-
table.insert(parts, state.current_model)
16+
if state.current_model then
17+
if config.ui.display_context_size then
18+
local provider, model = state.current_model:match('^(.-)/(.+)$')
19+
local model_info = config_file.get_model_info(provider, model)
20+
local limit = state.tokens_count and model_info and model_info.limit and model_info.limit.context or 0
21+
table.insert(parts, util.format_number(state.tokens_count))
22+
if limit > 0 then
23+
table.insert(parts, util.format_percentage(state.tokens_count / limit))
24+
end
25+
end
26+
if config.ui.display_cost then
27+
table.insert(parts, util.format_cost(state.cost))
1928
end
2029
end
2130

22-
return table.concat(parts, ' ')
23-
end
24-
25-
local function format_mode_info()
26-
return ' ' .. (state.current_mode or ''):upper() .. ' '
27-
end
28-
29-
local function get_mode_highlight()
30-
local mode = (state.current_mode or ''):lower()
31-
if mode == 'build' then
32-
return '%#OpencodeAgentBuild#'
33-
elseif mode == 'plan' then
34-
return '%#OpencodeAgentPlan#'
35-
else
36-
return '%#OpencodeAgentCustom#'
31+
local result = table.concat(parts, ' | ')
32+
if not result or type(result) ~= 'string' then
33+
result = ''
3734
end
35+
result = result:gsub('%%', '%%%%')
36+
return result
3837
end
3938

40-
local function create_winbar_text(description, model_info, mode_info, win_width)
39+
local function create_winbar_text(description, token_info, win_width)
4140
local left_content = ''
42-
local right_content = ''
43-
44-
right_content = model_info .. ' ' .. get_mode_highlight() .. mode_info .. '%*'
41+
local right_content = token_info
4542

4643
local desc_width = win_width - util.strdisplaywidth(left_content) - util.strdisplaywidth(right_content)
4744

@@ -76,16 +73,19 @@ local function update_winbar_highlights(win_id)
7673
end
7774

7875
local function get_session_desc()
79-
local session_desc = LABELS.NEW_SESSION_TITLE
76+
local session_title = LABELS.NEW_SESSION_TITLE
8077

8178
if state.active_session then
8279
local session = require('opencode.session').get_by_id(state.active_session.id)
8380
if session and session.title ~= '' then
84-
session_desc = session.title
81+
session_title = session.title
8582
end
8683
end
8784

88-
return session_desc
85+
if not session_title or type(session_title) ~= 'string' then
86+
session_title = ''
87+
end
88+
return session_title
8989
end
9090

9191
function M.render()
@@ -97,11 +97,13 @@ function M.render()
9797
if not win then
9898
return
9999
end
100-
-- topbar needs to at least have a value to make sure footer is positioned correctly
100+
101101
vim.wo[win].winbar = ' '
102102

103-
vim.wo[win].winbar =
104-
create_winbar_text(get_session_desc(), format_model_info(), format_mode_info(), vim.api.nvim_win_get_width(win))
103+
local desc = get_session_desc()
104+
local token_info = format_token_info()
105+
local winbar_str = create_winbar_text(desc, token_info, vim.api.nvim_win_get_width(win))
106+
vim.wo[win].winbar = winbar_str
105107

106108
update_winbar_highlights(win)
107109
end)
@@ -116,6 +118,8 @@ function M.setup()
116118
state.subscribe('current_model', on_change)
117119
state.subscribe('active_session', on_change)
118120
state.subscribe('is_opencode_focused', on_change)
121+
state.subscribe('tokens_count', on_change)
122+
state.subscribe('cost', on_change)
119123
M.render()
120124
end
121125

@@ -124,5 +128,7 @@ function M.close()
124128
state.unsubscribe('current_model', on_change)
125129
state.unsubscribe('active_session', on_change)
126130
state.unsubscribe('is_opencode_focused', on_change)
131+
state.unsubscribe('tokens_count', on_change)
132+
state.unsubscribe('cost', on_change)
127133
end
128134
return M

0 commit comments

Comments
 (0)