From a3b658cc9371f95fb2baccb96e89eb47640cbca8 Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Wed, 5 Nov 2025 08:06:42 +0100 Subject: [PATCH 1/4] feat(ui): Improve scroll at bottom to support scrolling without focus --- lua/opencode/state.lua | 2 ++ lua/opencode/ui/output_window.lua | 36 +++++++++++++++++++++++++++++++ lua/opencode/ui/renderer.lua | 28 +++++++++++++++--------- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 36730b54..fed6d063 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -14,6 +14,7 @@ ---@field last_input_window_position integer[]|nil ---@field last_output_window_position integer[]|nil ---@field last_code_win_before_opencode integer|nil +---@field output_window_at_bottom boolean ---@field current_code_buf number|nil ---@field display_route any|nil ---@field current_mode string @@ -55,6 +56,7 @@ local _state = { last_focused_opencode_window = nil, last_input_window_position = nil, last_output_window_position = nil, + output_window_at_bottom = true, last_code_win_before_opencode = nil, current_code_buf = nil, display_route = nil, diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index c69b754f..4add159b 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -27,6 +27,32 @@ function M.mounted(windows) return windows and windows.output_buf and windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) end +---Check if the output window is currently at the bottom +---@param win? integer Window ID, defaults to state.windows.output_win +---@return boolean true if at bottom, false otherwise +function M.is_at_bottom(win) + win = win or (state.windows and state.windows.output_win) + + if not win or not vim.api.nvim_win_is_valid(win) then + return true -- Assume at bottom if window invalid + end + + if not state.windows or not state.windows.output_buf then + return true + end + + local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf) + if not ok or not line_count or line_count == 0 then + return true -- Empty buffer, consider at bottom + end + + local botline = vim.fn.line('w$', win) + + -- Consider at bottom if bottom visible line is at or near the end + -- Use -1 tolerance for wrapped lines + return botline >= line_count - 1 +end + function M.setup(windows) vim.api.nvim_set_option_value('winhighlight', config.ui.window_highlight, { win = windows.output_win }) vim.api.nvim_set_option_value('wrap', true, { win = windows.output_win }) @@ -177,6 +203,16 @@ function M.setup_autocmds(windows, group) state.subscribe('current_permission', function() require('opencode.keymap').toggle_permission_keymap(windows.output_buf) end) + + -- Track scroll position when window is scrolled + vim.api.nvim_create_autocmd('WinScrolled', { + group = group, + buffer = windows.output_buf, + callback = function() + -- Update state to track if user is at bottom + state.output_window_at_bottom = M.is_at_bottom(windows.output_win) + end, + }) end function M.clear() diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 2b1762cf..b5b2f184 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -50,6 +50,10 @@ function M.reset() require('opencode.api').respond_to_permission('reject') end state.current_permission = nil + + -- Reset scroll state when session changes + state.output_window_at_bottom = true + trigger_on_data_rendered() end @@ -224,26 +228,30 @@ function M.scroll_to_bottom() return end - local botline = vim.fn.line('w$', state.windows.output_win) - local cursor = vim.api.nvim_win_get_cursor(state.windows.output_win) - local cursor_row = cursor[1] or 0 - local is_focused = vim.api.nvim_get_current_win() == state.windows.output_win - local prev_line_count = M._prev_line_count or 0 ---@cast line_count integer M._prev_line_count = line_count - local was_at_bottom = (botline >= prev_line_count) or prev_line_count == 0 - trigger_on_data_rendered() - if is_focused and cursor_row < prev_line_count - 1 then - return + -- Determine if we should scroll to bottom + local should_scroll = false + + -- Always scroll on initial render + if prev_line_count == 0 then + should_scroll = true + -- Scroll if user is at bottom (respects manual scroll position) + elseif state.output_window_at_bottom then + should_scroll = true end - if was_at_bottom or not is_focused then + if should_scroll then vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 }) + state.output_window_at_bottom = true + else + -- User has scrolled up, don't scroll + state.output_window_at_bottom = false end end From 999fd738822c387c1b342e78546c66871ff3ea79 Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Wed, 5 Nov 2025 19:23:42 +0100 Subject: [PATCH 2/4] feat(ui): Add config to always scroll output window to bottom --- lua/opencode/config.lua | 1 + lua/opencode/types.lua | 1 + lua/opencode/ui/output_window.lua | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index a13d7c4b..3fb9bf8d 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -102,6 +102,7 @@ M.defaults = { tools = { show_output = true, }, + always_scroll_to_bottom = false, }, input = { text = { diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 7e8f864d..81b2e62a 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -114,6 +114,7 @@ ---@class OpencodeUIOutputConfig ---@field tools { show_output: boolean } ---@field rendering OpencodeUIOutputRenderingConfig +---@field always_scroll_to_bottom boolean ---@class OpencodeContextConfig ---@field enabled boolean diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 4add159b..8dd00e52 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -31,6 +31,11 @@ end ---@param win? integer Window ID, defaults to state.windows.output_win ---@return boolean true if at bottom, false otherwise function M.is_at_bottom(win) + -- If always_scroll_to_bottom is enabled, always return true + if config.ui.output.always_scroll_to_bottom then + return true + end + win = win or (state.windows and state.windows.output_win) if not win or not vim.api.nvim_win_is_valid(win) then From 69fcb7597fa5349ee324dc5c05a5a4cb99ff4c0e Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Thu, 6 Nov 2025 07:41:17 +0100 Subject: [PATCH 3/4] refactor: Move output_at_bottom boolean from state to output_window --- lua/opencode/state.lua | 2 -- lua/opencode/ui/output_window.lua | 4 +++- lua/opencode/ui/renderer.lua | 9 +++------ 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index fed6d063..36730b54 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -14,7 +14,6 @@ ---@field last_input_window_position integer[]|nil ---@field last_output_window_position integer[]|nil ---@field last_code_win_before_opencode integer|nil ----@field output_window_at_bottom boolean ---@field current_code_buf number|nil ---@field display_route any|nil ---@field current_mode string @@ -56,7 +55,6 @@ local _state = { last_focused_opencode_window = nil, last_input_window_position = nil, last_output_window_position = nil, - output_window_at_bottom = true, last_code_win_before_opencode = nil, current_code_buf = nil, display_route = nil, diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 8dd00e52..d484e69a 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -3,6 +3,7 @@ local config = require('opencode.config') local M = {} M.namespace = vim.api.nvim_create_namespace('opencode_output') +M.viewport_at_bottom = true function M.create_buf() local output_buf = vim.api.nvim_create_buf(false, true) @@ -215,7 +216,7 @@ function M.setup_autocmds(windows, group) buffer = windows.output_buf, callback = function() -- Update state to track if user is at bottom - state.output_window_at_bottom = M.is_at_bottom(windows.output_win) + M.viewport_at_bottom = M.is_at_bottom(windows.output_win) end, }) end @@ -225,6 +226,7 @@ function M.clear() -- clear extmarks in all namespaces as I've seen RenderMarkdown leave some -- extmarks behind M.clear_extmarks(0, -1, true) + M.viewport_at_bottom = true end return M diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index b5b2f184..37a15a06 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -51,9 +51,6 @@ function M.reset() end state.current_permission = nil - -- Reset scroll state when session changes - state.output_window_at_bottom = true - trigger_on_data_rendered() end @@ -242,16 +239,16 @@ function M.scroll_to_bottom() if prev_line_count == 0 then should_scroll = true -- Scroll if user is at bottom (respects manual scroll position) - elseif state.output_window_at_bottom then + elseif output_window.viewport_at_bottom then should_scroll = true end if should_scroll then vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 }) - state.output_window_at_bottom = true + output_window.viewport_at_bottom = true else -- User has scrolled up, don't scroll - state.output_window_at_bottom = false + output_window.viewport_at_bottom = false end end From 082fd3d269a7cbdc09a6bb4fe0af5188df50ceda Mon Sep 17 00:00:00 2001 From: Guillaume BOEHM Date: Fri, 7 Nov 2025 07:26:09 +0100 Subject: [PATCH 4/4] chore(output_window): Remove some unnecessary comments --- lua/opencode/ui/output_window.lua | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index d484e69a..696a612e 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -32,7 +32,6 @@ end ---@param win? integer Window ID, defaults to state.windows.output_win ---@return boolean true if at bottom, false otherwise function M.is_at_bottom(win) - -- If always_scroll_to_bottom is enabled, always return true if config.ui.output.always_scroll_to_bottom then return true end @@ -40,7 +39,7 @@ function M.is_at_bottom(win) win = win or (state.windows and state.windows.output_win) if not win or not vim.api.nvim_win_is_valid(win) then - return true -- Assume at bottom if window invalid + return true end if not state.windows or not state.windows.output_buf then @@ -49,7 +48,7 @@ function M.is_at_bottom(win) local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf) if not ok or not line_count or line_count == 0 then - return true -- Empty buffer, consider at bottom + return true end local botline = vim.fn.line('w$', win) @@ -215,7 +214,6 @@ function M.setup_autocmds(windows, group) group = group, buffer = windows.output_buf, callback = function() - -- Update state to track if user is at bottom M.viewport_at_bottom = M.is_at_bottom(windows.output_win) end, })