Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 10 additions & 28 deletions lua/opencode/ui/output_window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick question: does viewport position still drive is_at_bottom() tracking in this change, or is it now cursor-only?

I often pull the viewport away from the bottom using the touchpad, so I want to confirm whether this part will cause a change in behavior.

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
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions lua/opencode/ui/renderer/scroll.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
local config = require('opencode.config')
local state = require('opencode.state')
local output_window = require('opencode.ui.output_window')

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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),
Expand Down
45 changes: 14 additions & 31 deletions tests/unit/cursor_tracking_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
Loading