From 5fc81e319fe18a48698a220ba4a42e5b74c9a838 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Sat, 14 Mar 2026 19:06:01 +0100 Subject: [PATCH 1/3] feat(ui): add configurable input window options Allow users to customize window-local options for the input window via `ui.input.win_options` in config. Any valid Neovim window option can be set, such as signcolumn, cursorline, number, relativenumber, etc. This improves flexibility for user preferences and editor appearance. --- lua/opencode/config.lua | 10 ++++++++++ lua/opencode/types.lua | 17 +++++++++++++++++ lua/opencode/ui/input_window.lua | 11 +++++++---- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 85c02c57..5053848c 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -160,6 +160,16 @@ M.defaults = { }, -- Auto-hide input window when prompt is submitted or focus switches to output window auto_hide = false, + -- Window-local options applied to the input window. + -- Any valid Neovim window option can be added here. + -- Users can override these and add any extra option, e.g.: + -- win_options = { signcolumn = 'no', cursorline = true, conceallevel = 2 } + win_options = { + signcolumn = 'yes', + cursorline = false, + number = false, + relativenumber = false, + }, }, picker = { snacks_layout = nil, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 6ad6aabf..9312af5c 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -142,11 +142,28 @@ ---@field highlights? OpencodeHighlightConfig ---@field picker OpencodeUIPickerConfig +---Window-local options applied to the input window. +---Any valid Neovim window-local option (`:h window-variable`) can be set here. +---Common examples: +--- signcolumn = 'no' +--- cursorline = true +--- number = true +--- relativenumber = true +--- foldcolumn = '0' +--- statuscolumn = '' +--- conceallevel = 2 +---@class OpencodeUIInputWinOptions : table +---@field signcolumn? string # Value for 'signcolumn' (e.g. 'yes', 'no', 'auto') +---@field cursorline? boolean +---@field number? boolean +---@field relativenumber? boolean + ---@class OpencodeUIInputConfig ---@field text { wrap: boolean } ---@field min_height number ---@field max_height number ---@field auto_hide boolean +---@field win_options? OpencodeUIInputWinOptions # Window-local options applied to the input window. Any valid Neovim window option is accepted. ---@class OpencodeHighlightConfig ---@field vertical_borders? { tool?: { fg?: string, bg?: string }, user?: { fg?: string, bg?: string }, assistant?: { fg?: string, bg?: string } } diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 903b61b3..9889081e 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -268,10 +268,13 @@ function M.setup(windows) set_buf_option('filetype', 'opencode', windows) set_win_option('winhighlight', config.ui.window_highlight, windows) - set_win_option('signcolumn', 'yes', windows) - set_win_option('cursorline', false, windows) - set_win_option('number', false, windows) - set_win_option('relativenumber', false, windows) + + -- Apply user-configurable window options + local win_opts = config.ui.input.win_options or {} + for opt, value in pairs(win_opts) do + pcall(set_win_option, opt, value, windows) + end + set_buf_option('buftype', 'nofile', windows) set_buf_option('bufhidden', 'hide', windows) set_buf_option('buflisted', false, windows) From f78500e56fd83b092ec6de29e9fc88c1800db80b Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Thu, 9 Apr 2026 19:56:29 +0200 Subject: [PATCH 2/3] refactor(ui): simplify output window scroll tracking Restore ed0c07815e867f3b71f4e012de4781cc24fb29e5 scroll behavior Replace the sticky `_was_at_bottom_by_win` flag with a new approach that tracks the previous line count per window. The `is_at_bottom` logic now compares the cursor position to the previous and current line counts, making auto-scroll behavior more robust and less dependent on viewport events. This also removes redundant comments and unused code, and ensures scroll tracking state is reset consistently. --- lua/opencode/ui/output_window.lua | 38 ++++++++--------------------- lua/opencode/ui/renderer/scroll.lua | 12 ++++++--- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 8857cf4d..ce160616 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -8,6 +8,7 @@ M.debug_namespace = vim.api.nvim_create_namespace('opencode_output_debug') M.markdown_namespace = vim.api.nvim_create_namespace('opencode_output_markdown') M._last_visible_bottom_by_win = {} M._was_at_bottom_by_win = {} +M._prev_line_count_by_win = {} local _update_depth = 0 local _update_buf = nil @@ -81,19 +82,9 @@ function M.buffer_valid(windows) return windows and windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf) end ----Check if the output window viewport is scrolled to the bottom of the buffer. ----Returns true if the output window should continue auto-scrolling to follow ----new content. Uses the viewport position (visible bottom line) rather than ----the cursor, so that mouse-wheel scrolling—which moves the viewport but not ----the cursor—correctly stops the tail-follow behavior. ---- ----The `_was_at_bottom_by_win` flag is the persistent signal: it is set to ----`true` by `scroll_win_to_bottom` and cleared to `false` by ----`sync_cursor_with_viewport` whenever the viewport is scrolled away from the ----buffer's last line. Reading a sticky flag (rather than the live viewport ----position) lets callers like `renderer.scroll_to_bottom()` that run *after* ----a buffer write still return the correct answer even though the viewport has ----not yet caught up to the newly appended lines. +---Check if the cursor in the output window is at (or was at) the bottom of +---the buffer, using the same logic as the original implementation. +---Returns true if the window should continue auto-scrolling. ---@param win? integer Window ID, defaults to state.windows.output_win ---@return boolean function M.is_at_bottom(win) @@ -116,18 +107,13 @@ function M.is_at_bottom(win) return true end - -- Prefer the sticky flag when it has been set by scroll/WinScrolled events. - -- Fall back to a live viewport check on the very first call (flag is nil). - if M._was_at_bottom_by_win[win] ~= nil then - return M._was_at_bottom_by_win[win] == true - end - - local visible_bottom = M.get_visible_bottom_line(win) - if not visible_bottom then + local ok2, cursor = pcall(vim.api.nvim_win_get_cursor, win) + if not ok2 then return true end - return visible_bottom >= line_count + local prev_line_count = M._prev_line_count_by_win[win] or line_count + return cursor[1] >= prev_line_count or cursor[1] >= line_count end ---@param win? integer @@ -146,11 +132,13 @@ function M.reset_scroll_tracking(win) if win then M._last_visible_bottom_by_win[win] = nil M._was_at_bottom_by_win[win] = nil + M._prev_line_count_by_win[win] = nil return end M._last_visible_bottom_by_win = {} M._was_at_bottom_by_win = {} + M._prev_line_count_by_win = {} end ---@param win? integer @@ -174,12 +162,6 @@ function M.sync_cursor_with_viewport(win) end M._last_visible_bottom_by_win[win] = visible_bottom - - -- Update the sticky at-bottom flag based on whether the viewport now shows - -- the last line. This is the key mechanism: when the user scrolls up (mouse - -- or keyboard), WinScrolled fires here and clears the flag so that the next - -- `is_at_bottom()` call returns false and streaming stops following the tail. - M._was_at_bottom_by_win[win] = visible_bottom >= line_count end ---@param windows OpencodeWindowState diff --git a/lua/opencode/ui/renderer/scroll.lua b/lua/opencode/ui/renderer/scroll.lua index 80f2346b..d2fd7312 100644 --- a/lua/opencode/ui/renderer/scroll.lua +++ b/lua/opencode/ui/renderer/scroll.lua @@ -1,4 +1,3 @@ -local config = require('opencode.config') local state = require('opencode.state') local output_window = require('opencode.ui.output_window') @@ -15,8 +14,6 @@ function M.get_output_win() end ---Move the cursor in `win` to the last line of `buf` and scroll so it's visible. ----Also marks the window as "at bottom" so that the next is_at_bottom() call ----returns true even when the buffer grew past the current viewport. ---@param win integer ---@param buf integer function M.scroll_win_to_bottom(win, buf) @@ -29,7 +26,7 @@ function M.scroll_win_to_bottom(win, buf) vim.api.nvim_win_call(win, function() vim.cmd('normal! zb') end) - output_window._was_at_bottom_by_win[win] = true + output_window._prev_line_count_by_win[win] = line_count end ---@param buf integer|nil @@ -44,6 +41,13 @@ function M.pre_flush(buf) return nil end + -- Snapshot the current line count before the buffer write so that + -- is_at_bottom() can compare cursor position against it after the write. + local ok, line_count = pcall(vim.api.nvim_buf_line_count, buf) + if ok and line_count and line_count > 0 then + output_window._prev_line_count_by_win[win] = line_count + end + return { win = win, follow = output_window.is_at_bottom(win), From 3af042badf6533fd19c383ddb3c79c3ed9b28f33 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Thu, 9 Apr 2026 20:10:02 +0200 Subject: [PATCH 3/3] refactor(tests): update output_window scroll tracking tests Update tests to reflect the new cursor-based scroll tracking logic in output_window. Remove references to the old viewport-based flag and simulate user actions by moving the cursor instead. This ensures tests align with the simplified auto-scroll behavior. --- tests/unit/cursor_tracking_spec.lua | 45 +++++++++-------------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index fde97b2e..1c2f0d38 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -220,9 +220,9 @@ describe('output_window.is_at_bottom', function() assert.is_true(output_window.is_at_bottom(win)) end) - it('returns false when _was_at_bottom_by_win flag is explicitly false', function() - -- Simulate user having scrolled away: flag is set to false - output_window._was_at_bottom_by_win[win] = false + it('returns false when cursor is not on last line', function() + -- cursor not at last line + vim.api.nvim_win_set_cursor(win, { 25, 0 }) assert.is_false(output_window.is_at_bottom(win)) end) @@ -232,16 +232,13 @@ describe('output_window.is_at_bottom', function() end) it('returns false when user has scrolled viewport away from bottom', function() - -- Simulate scrolling to bottom then user scrolling away + -- Simulate scrolling to bottom then user pressing k to move cursor up local scroll = require('opencode.ui.renderer.scroll') scroll.scroll_win_to_bottom(win, buf) assert.is_true(output_window.is_at_bottom(win)) - -- Simulate WinScrolled: user scrolls viewport up - pcall(vim.api.nvim_win_call, win, function() - vim.fn.winrestview({ topline = 1 }) - end) - output_window.sync_cursor_with_viewport(win) + -- User presses k: cursor moves away from the last line + vim.api.nvim_win_set_cursor(win, { 40, 0 }) assert.is_false(output_window.is_at_bottom(win)) end) @@ -278,20 +275,14 @@ describe('output_window.is_at_bottom', function() pcall(vim.api.nvim_buf_delete, empty_buf, { force = true }) end) - it('viewport-based: scrolling viewport up stops auto-scroll even when cursor stays at last line', function() - -- Scroll to bottom so _was_at_bottom_by_win is set to true + it('cursor-based: moving cursor up stops auto-scroll', function() + -- Scroll to bottom: cursor is at last line local scroll = require('opencode.ui.renderer.scroll') scroll.scroll_win_to_bottom(win, buf) assert.is_true(output_window.is_at_bottom(win)) - -- Scroll the viewport up without touching the cursor. - -- WinScrolled fires → sync_cursor_with_viewport → _was_at_bottom_by_win = false - pcall(vim.api.nvim_win_call, win, function() - vim.fn.winrestview({ topline = 1 }) - end) - output_window.sync_cursor_with_viewport(win) - - -- Even though cursor is still at line 50, viewport has scrolled away + -- User presses k: cursor moves away from last line → auto-scroll stops + vim.api.nvim_win_set_cursor(win, { 40, 0 }) assert.is_false(output_window.is_at_bottom(win)) end) @@ -352,20 +343,12 @@ describe('output_window.sync_cursor_with_viewport', function() state.ui.set_windows(nil) end) - it('sets _was_at_bottom_by_win to false when viewport scrolls away from bottom', function() - -- Start with viewport and cursor at last line - vim.api.nvim_win_set_cursor(win, { 5, 0 }) - local scroll = require('opencode.ui.renderer.scroll') - scroll.scroll_win_to_bottom(win, buf) - assert.is_true(output_window._was_at_bottom_by_win[win]) - - -- Scroll the viewport up (simulate mouse wheel scroll) - pcall(vim.api.nvim_win_call, win, function() - vim.fn.winrestview({ topline = 1 }) - end) + it('does not affect is_at_bottom when cursor is away from bottom', function() + -- cursor not at last line + vim.api.nvim_win_set_cursor(win, { 2, 0 }) output_window.sync_cursor_with_viewport(win) - assert.is_false(output_window._was_at_bottom_by_win[win]) + assert.is_false(output_window.is_at_bottom(win)) end) it('does not move the cursor when the user is already reading earlier content', function()