From bb4328c6424e45880322e9053167f1c48f3195e8 Mon Sep 17 00:00:00 2001 From: Dmitrij Vinokour Date: Tue, 5 May 2026 20:09:39 +0200 Subject: [PATCH] feat(ui): float ui --- lua/opencode/config.lua | 12 +++ lua/opencode/state/ui.lua | 2 +- lua/opencode/types.lua | 13 ++- lua/opencode/ui/float_layout.lua | 144 ++++++++++++++++++++++++++++++ lua/opencode/ui/input_window.lua | 33 +++++++ lua/opencode/ui/output_window.lua | 6 ++ lua/opencode/ui/ui.lua | 16 ++++ 7 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 lua/opencode/ui/float_layout.lua diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index d86b35ec..315344a9 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -133,6 +133,18 @@ M.defaults = { input_position = 'bottom', window_width = 0.40, zoom_width = 0.8, + float = { + width = 0.95, + height = 0.9, + row = nil, + col = nil, + border = 'rounded', + gap = 1, + zindex = 40, + opts = { + winblend = 0, + }, + }, picker_width = 100, display_model = true, display_context_size = true, diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index cadc627e..16831d85 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -13,7 +13,7 @@ local store = require('opencode.state.store') ---@field output_cursor integer[]|nil ---@field output_view table|nil ---@field focused_window 'input'|'output'|nil ----@field position 'right'|'left'|'current'|nil +---@field position 'right'|'left'|'current'|'float'|nil ---@field owner_tab integer|nil ---@class OpencodeWindowState diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 1f8972b6..c418b199 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -188,13 +188,24 @@ ---@field path_map (string | fun(host_path: string): string) | nil -- Map host paths to server paths ---@field reverse_path_map (fun(server_path: string): string) | nil -- Map server paths back to host paths +---@class OpencodeUIFloatConfig +---@field width number # Width in columns, or ratio when <= 1 (default: 0.95) +---@field height number # Height in rows, or ratio when <= 1 (default: 0.9) +---@field row number|nil # Top row, centered when nil +---@field col number|nil # Left column, centered when nil +---@field border string|string[]|nil # Float border passed to nvim_open_win +---@field gap integer # Rows between output and input floats +---@field zindex integer # Output float zindex; input uses zindex + 1 +---@field opts table # Window-local options applied to float windows + ---@class OpencodeUIConfig ---@field enable_treesitter_markdown boolean ----@field position 'right'|'left'|'current' # Position of the UI (default: 'right') +---@field position 'right'|'left'|'current'|'float' # Position of the UI (default: 'right') ---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom') ---@field window_width number ---@field persist_state boolean ---@field zoom_width number +---@field float OpencodeUIFloatConfig ---@field picker_width number|nil # Default width for all pickers (nil uses current window width) ---@field display_model boolean ---@field display_context_size boolean diff --git a/lua/opencode/ui/float_layout.lua b/lua/opencode/ui/float_layout.lua new file mode 100644 index 00000000..75227cd7 --- /dev/null +++ b/lua/opencode/ui/float_layout.lua @@ -0,0 +1,144 @@ +local config = require('opencode.config') +local state = require('opencode.state') + +local M = {} + +---@param value number|nil +---@param total integer +---@param fallback number +---@return integer +local function resolve_dimension(value, total, fallback) + local resolved = value or fallback + if resolved > 0 and resolved <= 1 then + return math.floor(total * resolved) + end + return math.floor(resolved) +end + +---@param value number|nil +---@param total integer +---@param size integer +---@return integer +local function resolve_position(value, total, size) + if value == nil then + return math.floor((total - size) / 2) + end + if value > 0 and value <= 1 then + return math.floor(total * value) + end + return math.floor(value) +end + +---@param value integer +---@param min_value integer +---@param max_value integer +---@return integer +local function clamp(value, min_value, max_value) + return math.min(max_value, math.max(min_value, value)) +end + +---@param windows OpencodeWindowState|nil +---@return integer +local function input_height(windows) + local line_count = 1 + if windows and windows.input_buf and vim.api.nvim_buf_is_valid(windows.input_buf) then + line_count = vim.api.nvim_buf_line_count(windows.input_buf) + end + + local min_height = math.max(1, math.floor(vim.o.lines * config.ui.input.min_height)) + local max_height = math.max(min_height, math.floor(vim.o.lines * config.ui.input.max_height)) + return clamp(line_count, min_height, max_height) +end + +---@param windows OpencodeWindowState|nil +---@return vim.api.keyset.win_config +local function base_config(windows) + local float = config.ui.float or {} + local width + if windows and windows.saved_width_ratio then + width = math.floor(vim.o.columns * windows.saved_width_ratio) + windows.saved_width_ratio = nil + elseif state.pre_zoom_width then + width = math.floor(vim.o.columns * config.ui.zoom_width) + else + width = resolve_dimension(float.width, vim.o.columns, 0.95) + end + local height = resolve_dimension(float.height, vim.o.lines, 0.9) + + return { + relative = 'editor', + width = width, + height = height, + row = resolve_position(float.row, vim.o.lines, height), + col = resolve_position(float.col, vim.o.columns, width), + style = 'minimal', + border = float.border, + } +end + +---@param windows OpencodeWindowState|nil +---@param show_input boolean +---@return vim.api.keyset.win_config output_config +function M.window_configs(windows, show_input) + local float = config.ui.float or {} + local base = base_config(windows) + local prompt_height = show_input and input_height(windows) or 0 + local gap = show_input and (float.gap or 1) or 0 + local output_height = math.max(1, base.height - prompt_height - gap) + + local output_config = vim.tbl_deep_extend('force', base, { + height = output_height, + zindex = float.zindex or 40, + }) + + if not show_input then + return output_config, nil + end + + local input_config = vim.tbl_deep_extend('force', base, { + height = prompt_height, + zindex = (float.zindex or 40) + 1, + }) + + if config.ui.input_position == 'top' then + output_config.row = base.row + prompt_height + gap + else + input_config.row = base.row + output_height + gap + end + + return output_config, input_config +end + +---@param buf integer +---@param enter boolean +---@param win_config vim.api.keyset.win_config +---@return integer +function M.open_win(buf, enter, win_config) + local win = vim.api.nvim_open_win(buf, enter, win_config) + local float = config.ui.float or {} + for opt, value in pairs(float.opts or {}) do + pcall(vim.api.nvim_set_option_value, opt, value, { win = win, scope = 'local' }) + end + return win +end + +---@param windows OpencodeWindowState|nil +---@param show_input boolean +function M.update(windows, show_input) + if not windows or not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then + return + end + + local output_config, input_config = M.window_configs(windows, show_input) + pcall(vim.api.nvim_win_set_config, windows.output_win, output_config) + + if show_input and input_config and windows.input_win and vim.api.nvim_win_is_valid(windows.input_win) then + pcall(vim.api.nvim_win_set_config, windows.input_win, input_config) + end + + if windows.footer_win and vim.api.nvim_win_is_valid(windows.footer_win) then + require('opencode.ui.footer').update_window(windows) + end +end + +return M diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 7c572dfd..ae1aa1e0 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -3,6 +3,7 @@ local M = {} local state = require('opencode.state') local config = require('opencode.config') local window_options = require('opencode.ui.window_options') +local float_layout = require('opencode.ui.float_layout') -- Track hidden state M._hidden = false @@ -87,6 +88,12 @@ function M._build_input_win_config() end function M.create_window(windows) + if config.ui.position == 'float' then + local _, input_config = float_layout.window_configs(windows, true) + windows.input_win = float_layout.open_win(windows.input_buf, true, input_config) + return + end + windows.input_win = vim.api.nvim_open_win(windows.input_buf, true, M._build_input_win_config()) end @@ -288,6 +295,11 @@ function M.update_dimensions(windows) return end + if config.ui.position == 'float' then + float_layout.update(windows, true) + return + end + local height = calculate_height(windows) apply_dimensions(windows, height) end @@ -560,6 +572,10 @@ function M._hide() pcall(vim.api.nvim_win_close, windows.input_win, false) windows.input_win = nil + if config.ui.position == 'float' then + float_layout.update(windows, false) + end + vim.schedule(function() M._toggling = false end) @@ -593,6 +609,23 @@ function M._show() if not vim.api.nvim_win_is_valid(output_win) then return end + + if config.ui.position == 'float' then + local output_config, input_config = float_layout.window_configs(windows, true) + pcall(vim.api.nvim_win_set_config, output_win, output_config) + windows.input_win = float_layout.open_win(windows.input_buf, true, input_config) + M.setup(windows) + M._hidden = false + M.focus_input() + + if was_at_bottom then + vim.schedule(function() + require('opencode.ui.renderer').scroll_to_bottom(true) + end) + end + return + end + vim.api.nvim_set_current_win(output_win) local input_position = config.ui.input_position or 'bottom' diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 3d6fc68e..a9d67dde 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -1,6 +1,7 @@ local state = require('opencode.state') local config = require('opencode.config') local window_options = require('opencode.ui.window_options') +local float_layout = require('opencode.ui.float_layout') local M = {} M.namespace = vim.api.nvim_create_namespace('opencode_output') @@ -216,6 +217,11 @@ function M.update_dimensions(windows) return end + if config.ui.position == 'float' then + float_layout.update(windows, windows.input_win ~= nil and vim.api.nvim_win_is_valid(windows.input_win)) + return + end + local total_width = vim.api.nvim_get_option_value('columns', {}) local width_ratio diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index d8178dbb..56550afd 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -3,6 +3,7 @@ local state = require('opencode.state') local renderer = require('opencode.ui.renderer') local output_window = require('opencode.ui.output_window') local input_window = require('opencode.ui.input_window') +local float_layout = require('opencode.ui.float_layout') local footer = require('opencode.ui.footer') local topbar = require('opencode.ui.topbar') @@ -314,6 +315,17 @@ local function open_split(direction, type) return vim.api.nvim_get_current_win() end +---@param input_buf integer +---@param output_buf integer +---@return { input_win: integer, output_win: integer } +local function open_float(input_buf, output_buf) + local output_config, input_config = float_layout.window_configs({ input_buf = input_buf, output_buf = output_buf }, true) + local output_win = float_layout.open_win(output_buf, true, output_config) + local input_win = float_layout.open_win(input_buf, true, input_config) + + return { input_win = input_win, output_win = output_win } +end + ---@param input_buf integer ---@param output_buf integer ---@return { input_win: integer, output_win: integer } @@ -323,6 +335,10 @@ function M.create_split_windows(input_buf, output_buf) end local ui_conf = config.ui + if ui_conf.position == 'float' then + return open_float(input_buf, output_buf) + end + local main_win if ui_conf.position == 'current' then main_win = vim.api.nvim_get_current_win()