diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 764b5bff..8509d0eb 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -277,6 +277,40 @@ function M.setup_autocmds(windows, group) state.save_cursor_position('output', windows.output_win) end, }) + + vim.api.nvim_create_autocmd('WinScrolled', { + group = group, + buffer = windows.output_buf, + callback = function() + if not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then + return + end + + local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win) + if not ok then + return + end + + local ok2, line_count = pcall(vim.api.nvim_buf_line_count, windows.output_buf) + if not ok2 or line_count == 0 then + return + end + + if cursor[1] >= line_count then + local ok3, view = pcall(vim.api.nvim_win_call, windows.output_win, vim.fn.winsaveview) + if ok3 and type(view) == 'table' then + local topline = view.topline or 1 + local win_height = vim.api.nvim_win_get_height(windows.output_win) + local visible_bottom = math.min(topline + win_height - 1, line_count) + + if visible_bottom < line_count then + pcall(vim.api.nvim_win_set_cursor, windows.output_win, { visible_bottom, 0 }) + state.save_cursor_position('output', windows.output_win) + end + end + end + end, + }) end function M.clear() diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 28dc154c..be059c5f 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -299,15 +299,19 @@ end ---Respects cursor position if user has scrolled up ---@param force? boolean If true, scroll regardless of current position function M.scroll_to_bottom(force) - if not state.windows or not state.windows.output_buf or not state.windows.output_win then + local windows = state.windows + local output_win = windows and windows.output_win + local output_buf = windows and windows.output_buf + + if not output_buf or not output_win then return end - if not vim.api.nvim_win_is_valid(state.windows.output_win) then + if not vim.api.nvim_win_is_valid(output_win) then return end - local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf) + local ok, line_count = pcall(vim.api.nvim_buf_line_count, output_buf) if not ok or line_count == 0 then return end @@ -319,28 +323,45 @@ function M.scroll_to_bottom(force) trigger_on_data_rendered() - -- Determine if we should scroll to bottom - local should_scroll = force == true + local scroll_conditions = { + { + name = 'force', + test = function() + return force == true + end, + }, + { + name = 'first_render', + test = function() + return prev_line_count == 0 + end, + }, + { + name = 'always_scroll', + test = function() + return config.ui.output.always_scroll_to_bottom + end, + }, + { + name = 'cursor_at_bottom', + test = function() + local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, output_win) + return ok_cursor and cursor and (cursor[1] >= prev_line_count or cursor[1] >= line_count) + end, + }, + } - if not should_scroll then - -- Always scroll on initial render - if prev_line_count == 0 then - should_scroll = true - -- Respect explicit config to always follow output - elseif config.ui.output.always_scroll_to_bottom then + local should_scroll = false + for _, condition in ipairs(scroll_conditions) do + if condition.test() then should_scroll = true - -- Scroll if user is at bottom (respects manual scroll position) - else - local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, state.windows.output_win) - local cursor_row = ok_cursor and cursor[1] or 1 - should_scroll = cursor_row >= prev_line_count + break end end if should_scroll then - vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 }) - -- Use zb to position the cursor line at the bottom of the visible window - vim.api.nvim_win_call(state.windows.output_win, function() + vim.api.nvim_win_set_cursor(output_win, { line_count, 0 }) + vim.api.nvim_win_call(output_win, function() vim.cmd('normal! zb') end) end diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 38510e05..9b3e18a8 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -8,6 +8,89 @@ describe('cursor persistence (state)', function() state.set_cursor_position('output', nil) end) + describe('renderer.scroll_to_bottom', function() + local renderer = require('opencode.ui.renderer') + local buf, win + + before_each(function() + config.setup({}) + renderer.reset() + + buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + 'line 6', + 'line 7', + 'line 8', + 'line 9', + 'line 10', + }) + + win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + + state.windows = { output_win = win, output_buf = buf } + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_set_cursor(win, { 10, 0 }) + end) + + after_each(function() + renderer.reset() + pcall(vim.api.nvim_win_close, win, true) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + state.windows = nil + end) + + it('auto-scrolls when cursor was at previous bottom and buffer grows', function() + renderer.scroll_to_bottom() + + vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' }) + renderer.scroll_to_bottom() + + local cursor = vim.api.nvim_win_get_cursor(win) + assert.equals(12, cursor[1]) + end) + + it('does not auto-scroll when user moved away from previous bottom before growth', function() + renderer.scroll_to_bottom() + + vim.api.nvim_win_set_cursor(win, { 5, 0 }) + vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' }) + renderer.scroll_to_bottom() + + local cursor = vim.api.nvim_win_get_cursor(win) + assert.equals(5, cursor[1]) + end) + + it('auto-scrolls even when output window is unfocused if cursor was at previous bottom', function() + renderer.scroll_to_bottom() + + local input_buf = vim.api.nvim_create_buf(false, true) + vim.cmd('vsplit') + local input_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(input_win, input_buf) + vim.api.nvim_set_current_win(input_win) + + vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11' }) + renderer.scroll_to_bottom() + + local cursor = vim.api.nvim_win_get_cursor(win) + assert.equals(11, cursor[1]) + + pcall(vim.api.nvim_win_close, input_win, true) + pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) + end) + end) + describe('set/get round-trip', function() it('stores and retrieves input cursor', function() state.set_cursor_position('input', { 5, 3 }) @@ -83,7 +166,11 @@ describe('cursor persistence (state)', function() local buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line1', 'line2', 'line3' }) local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', width = 40, height = 10, row = 0, col = 0, + relative = 'editor', + width = 40, + height = 10, + row = 0, + col = 0, }) vim.api.nvim_win_set_cursor(win, { 2, 3 }) @@ -111,7 +198,11 @@ describe('output_window.is_at_bottom', function() vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', width = 80, height = 10, row = 0, col = 0, + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, }) state.windows = { output_win = win, output_buf = buf } @@ -162,7 +253,11 @@ describe('output_window.is_at_bottom', function() it('returns true for empty buffer', function() local empty_buf = vim.api.nvim_create_buf(false, true) local empty_win = vim.api.nvim_open_win(empty_buf, true, { - relative = 'editor', width = 40, height = 5, row = 0, col = 0, + relative = 'editor', + width = 40, + height = 5, + row = 0, + col = 0, }) state.windows = { output_win = empty_win, output_buf = empty_buf } @@ -202,7 +297,11 @@ describe('renderer.scroll_to_bottom', function() vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', width = 80, height = 10, row = 0, col = 0, + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, }) state.windows = { output_win = win, output_buf = buf } @@ -251,10 +350,18 @@ describe('ui.focus_input', function() vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'output' }) output_win = vim.api.nvim_open_win(output_buf, true, { - relative = 'editor', width = 40, height = 5, row = 0, col = 0, + relative = 'editor', + width = 40, + height = 5, + row = 0, + col = 0, }) input_win = vim.api.nvim_open_win(input_buf, true, { - relative = 'editor', width = 40, height = 5, row = 6, col = 0, + relative = 'editor', + width = 40, + height = 5, + row = 6, + col = 0, }) state.windows = { @@ -297,7 +404,11 @@ describe('renderer._add_message_to_buffer scrolling', function() vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'existing line' }) win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', width = 80, height = 10, row = 0, col = 0, + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, }) state.windows = { output_win = win, output_buf = buf }