Skip to content

Commit 32860d7

Browse files
feat(ui): float ui (#371)
Co-authored-by: Dmitrij Vinokour <dmitrij.vinokour@seturion.com>
1 parent 33657e1 commit 32860d7

7 files changed

Lines changed: 224 additions & 2 deletions

File tree

lua/opencode/config.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ M.defaults = {
133133
input_position = 'bottom',
134134
window_width = 0.40,
135135
zoom_width = 0.8,
136+
float = {
137+
width = 0.95,
138+
height = 0.9,
139+
row = nil,
140+
col = nil,
141+
border = 'rounded',
142+
gap = 1,
143+
zindex = 40,
144+
opts = {
145+
winblend = 0,
146+
},
147+
},
136148
picker_width = 100,
137149
display_model = true,
138150
display_context_size = true,

lua/opencode/state/ui.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ local store = require('opencode.state.store')
1313
---@field output_cursor integer[]|nil
1414
---@field output_view table|nil
1515
---@field focused_window 'input'|'output'|nil
16-
---@field position 'right'|'left'|'current'|nil
16+
---@field position 'right'|'left'|'current'|'float'|nil
1717
---@field owner_tab integer|nil
1818

1919
---@class OpencodeWindowState

lua/opencode/types.lua

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,24 @@
188188
---@field path_map (string | fun(host_path: string): string) | nil -- Map host paths to server paths
189189
---@field reverse_path_map (fun(server_path: string): string) | nil -- Map server paths back to host paths
190190

191+
---@class OpencodeUIFloatConfig
192+
---@field width number # Width in columns, or ratio when <= 1 (default: 0.95)
193+
---@field height number # Height in rows, or ratio when <= 1 (default: 0.9)
194+
---@field row number|nil # Top row, centered when nil
195+
---@field col number|nil # Left column, centered when nil
196+
---@field border string|string[]|nil # Float border passed to nvim_open_win
197+
---@field gap integer # Rows between output and input floats
198+
---@field zindex integer # Output float zindex; input uses zindex + 1
199+
---@field opts table<string, any> # Window-local options applied to float windows
200+
191201
---@class OpencodeUIConfig
192202
---@field enable_treesitter_markdown boolean
193-
---@field position 'right'|'left'|'current' # Position of the UI (default: 'right')
203+
---@field position 'right'|'left'|'current'|'float' # Position of the UI (default: 'right')
194204
---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom')
195205
---@field window_width number
196206
---@field persist_state boolean
197207
---@field zoom_width number
208+
---@field float OpencodeUIFloatConfig
198209
---@field picker_width number|nil # Default width for all pickers (nil uses current window width)
199210
---@field display_model boolean
200211
---@field display_context_size boolean

lua/opencode/ui/float_layout.lua

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
local config = require('opencode.config')
2+
local state = require('opencode.state')
3+
4+
local M = {}
5+
6+
---@param value number|nil
7+
---@param total integer
8+
---@param fallback number
9+
---@return integer
10+
local function resolve_dimension(value, total, fallback)
11+
local resolved = value or fallback
12+
if resolved > 0 and resolved <= 1 then
13+
return math.floor(total * resolved)
14+
end
15+
return math.floor(resolved)
16+
end
17+
18+
---@param value number|nil
19+
---@param total integer
20+
---@param size integer
21+
---@return integer
22+
local function resolve_position(value, total, size)
23+
if value == nil then
24+
return math.floor((total - size) / 2)
25+
end
26+
if value > 0 and value <= 1 then
27+
return math.floor(total * value)
28+
end
29+
return math.floor(value)
30+
end
31+
32+
---@param value integer
33+
---@param min_value integer
34+
---@param max_value integer
35+
---@return integer
36+
local function clamp(value, min_value, max_value)
37+
return math.min(max_value, math.max(min_value, value))
38+
end
39+
40+
---@param windows OpencodeWindowState|nil
41+
---@return integer
42+
local function input_height(windows)
43+
local line_count = 1
44+
if windows and windows.input_buf and vim.api.nvim_buf_is_valid(windows.input_buf) then
45+
line_count = vim.api.nvim_buf_line_count(windows.input_buf)
46+
end
47+
48+
local min_height = math.max(1, math.floor(vim.o.lines * config.ui.input.min_height))
49+
local max_height = math.max(min_height, math.floor(vim.o.lines * config.ui.input.max_height))
50+
return clamp(line_count, min_height, max_height)
51+
end
52+
53+
---@param windows OpencodeWindowState|nil
54+
---@return vim.api.keyset.win_config
55+
local function base_config(windows)
56+
local float = config.ui.float or {}
57+
local width
58+
if windows and windows.saved_width_ratio then
59+
width = math.floor(vim.o.columns * windows.saved_width_ratio)
60+
windows.saved_width_ratio = nil
61+
elseif state.pre_zoom_width then
62+
width = math.floor(vim.o.columns * config.ui.zoom_width)
63+
else
64+
width = resolve_dimension(float.width, vim.o.columns, 0.95)
65+
end
66+
local height = resolve_dimension(float.height, vim.o.lines, 0.9)
67+
68+
return {
69+
relative = 'editor',
70+
width = width,
71+
height = height,
72+
row = resolve_position(float.row, vim.o.lines, height),
73+
col = resolve_position(float.col, vim.o.columns, width),
74+
style = 'minimal',
75+
border = float.border,
76+
}
77+
end
78+
79+
---@param windows OpencodeWindowState|nil
80+
---@param show_input boolean
81+
---@return vim.api.keyset.win_config output_config
82+
function M.window_configs(windows, show_input)
83+
local float = config.ui.float or {}
84+
local base = base_config(windows)
85+
local prompt_height = show_input and input_height(windows) or 0
86+
local gap = show_input and (float.gap or 1) or 0
87+
local output_height = math.max(1, base.height - prompt_height - gap)
88+
89+
local output_config = vim.tbl_deep_extend('force', base, {
90+
height = output_height,
91+
zindex = float.zindex or 40,
92+
})
93+
94+
if not show_input then
95+
return output_config, nil
96+
end
97+
98+
local input_config = vim.tbl_deep_extend('force', base, {
99+
height = prompt_height,
100+
zindex = (float.zindex or 40) + 1,
101+
})
102+
103+
if config.ui.input_position == 'top' then
104+
output_config.row = base.row + prompt_height + gap
105+
else
106+
input_config.row = base.row + output_height + gap
107+
end
108+
109+
return output_config, input_config
110+
end
111+
112+
---@param buf integer
113+
---@param enter boolean
114+
---@param win_config vim.api.keyset.win_config
115+
---@return integer
116+
function M.open_win(buf, enter, win_config)
117+
local win = vim.api.nvim_open_win(buf, enter, win_config)
118+
local float = config.ui.float or {}
119+
for opt, value in pairs(float.opts or {}) do
120+
pcall(vim.api.nvim_set_option_value, opt, value, { win = win, scope = 'local' })
121+
end
122+
return win
123+
end
124+
125+
---@param windows OpencodeWindowState|nil
126+
---@param show_input boolean
127+
function M.update(windows, show_input)
128+
if not windows or not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then
129+
return
130+
end
131+
132+
local output_config, input_config = M.window_configs(windows, show_input)
133+
pcall(vim.api.nvim_win_set_config, windows.output_win, output_config)
134+
135+
if show_input and input_config and windows.input_win and vim.api.nvim_win_is_valid(windows.input_win) then
136+
pcall(vim.api.nvim_win_set_config, windows.input_win, input_config)
137+
end
138+
139+
if windows.footer_win and vim.api.nvim_win_is_valid(windows.footer_win) then
140+
require('opencode.ui.footer').update_window(windows)
141+
end
142+
end
143+
144+
return M

lua/opencode/ui/input_window.lua

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ local M = {}
33
local state = require('opencode.state')
44
local config = require('opencode.config')
55
local window_options = require('opencode.ui.window_options')
6+
local float_layout = require('opencode.ui.float_layout')
67

78
-- Track hidden state
89
M._hidden = false
@@ -87,6 +88,12 @@ function M._build_input_win_config()
8788
end
8889

8990
function M.create_window(windows)
91+
if config.ui.position == 'float' then
92+
local _, input_config = float_layout.window_configs(windows, true)
93+
windows.input_win = float_layout.open_win(windows.input_buf, true, input_config)
94+
return
95+
end
96+
9097
windows.input_win = vim.api.nvim_open_win(windows.input_buf, true, M._build_input_win_config())
9198
end
9299

@@ -288,6 +295,11 @@ function M.update_dimensions(windows)
288295
return
289296
end
290297

298+
if config.ui.position == 'float' then
299+
float_layout.update(windows, true)
300+
return
301+
end
302+
291303
local height = calculate_height(windows)
292304
apply_dimensions(windows, height)
293305
end
@@ -560,6 +572,10 @@ function M._hide()
560572
pcall(vim.api.nvim_win_close, windows.input_win, false)
561573
windows.input_win = nil
562574

575+
if config.ui.position == 'float' then
576+
float_layout.update(windows, false)
577+
end
578+
563579
vim.schedule(function()
564580
M._toggling = false
565581
end)
@@ -593,6 +609,23 @@ function M._show()
593609
if not vim.api.nvim_win_is_valid(output_win) then
594610
return
595611
end
612+
613+
if config.ui.position == 'float' then
614+
local output_config, input_config = float_layout.window_configs(windows, true)
615+
pcall(vim.api.nvim_win_set_config, output_win, output_config)
616+
windows.input_win = float_layout.open_win(windows.input_buf, true, input_config)
617+
M.setup(windows)
618+
M._hidden = false
619+
M.focus_input()
620+
621+
if was_at_bottom then
622+
vim.schedule(function()
623+
require('opencode.ui.renderer').scroll_to_bottom(true)
624+
end)
625+
end
626+
return
627+
end
628+
596629
vim.api.nvim_set_current_win(output_win)
597630

598631
local input_position = config.ui.input_position or 'bottom'

lua/opencode/ui/output_window.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local state = require('opencode.state')
22
local config = require('opencode.config')
33
local window_options = require('opencode.ui.window_options')
4+
local float_layout = require('opencode.ui.float_layout')
45

56
local M = {}
67
M.namespace = vim.api.nvim_create_namespace('opencode_output')
@@ -216,6 +217,11 @@ function M.update_dimensions(windows)
216217
return
217218
end
218219

220+
if config.ui.position == 'float' then
221+
float_layout.update(windows, windows.input_win ~= nil and vim.api.nvim_win_is_valid(windows.input_win))
222+
return
223+
end
224+
219225
local total_width = vim.api.nvim_get_option_value('columns', {})
220226

221227
local width_ratio

lua/opencode/ui/ui.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ local state = require('opencode.state')
33
local renderer = require('opencode.ui.renderer')
44
local output_window = require('opencode.ui.output_window')
55
local input_window = require('opencode.ui.input_window')
6+
local float_layout = require('opencode.ui.float_layout')
67
local footer = require('opencode.ui.footer')
78
local topbar = require('opencode.ui.topbar')
89

@@ -314,6 +315,17 @@ local function open_split(direction, type)
314315
return vim.api.nvim_get_current_win()
315316
end
316317

318+
---@param input_buf integer
319+
---@param output_buf integer
320+
---@return { input_win: integer, output_win: integer }
321+
local function open_float(input_buf, output_buf)
322+
local output_config, input_config = float_layout.window_configs({ input_buf = input_buf, output_buf = output_buf }, true)
323+
local output_win = float_layout.open_win(output_buf, true, output_config)
324+
local input_win = float_layout.open_win(input_buf, true, input_config)
325+
326+
return { input_win = input_win, output_win = output_win }
327+
end
328+
317329
---@param input_buf integer
318330
---@param output_buf integer
319331
---@return { input_win: integer, output_win: integer }
@@ -323,6 +335,10 @@ function M.create_split_windows(input_buf, output_buf)
323335
end
324336
local ui_conf = config.ui
325337

338+
if ui_conf.position == 'float' then
339+
return open_float(input_buf, output_buf)
340+
end
341+
326342
local main_win
327343
if ui_conf.position == 'current' then
328344
main_win = vim.api.nvim_get_current_win()

0 commit comments

Comments
 (0)