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), 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()