Skip to content

Commit 0ce125b

Browse files
authored
feat: restore scroll behavior (#353)
1 parent 1758b92 commit 0ce125b

3 files changed

Lines changed: 32 additions & 63 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ M.debug_namespace = vim.api.nvim_create_namespace('opencode_output_debug')
88
M.markdown_namespace = vim.api.nvim_create_namespace('opencode_output_markdown')
99
M._last_visible_bottom_by_win = {}
1010
M._was_at_bottom_by_win = {}
11+
M._prev_line_count_by_win = {}
1112

1213
local _update_depth = 0
1314
local _update_buf = nil
@@ -81,19 +82,9 @@ function M.buffer_valid(windows)
8182
return windows and windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf)
8283
end
8384

84-
---Check if the output window viewport is scrolled to the bottom of the buffer.
85-
---Returns true if the output window should continue auto-scrolling to follow
86-
---new content. Uses the viewport position (visible bottom line) rather than
87-
---the cursor, so that mouse-wheel scrolling—which moves the viewport but not
88-
---the cursor—correctly stops the tail-follow behavior.
89-
---
90-
---The `_was_at_bottom_by_win` flag is the persistent signal: it is set to
91-
---`true` by `scroll_win_to_bottom` and cleared to `false` by
92-
---`sync_cursor_with_viewport` whenever the viewport is scrolled away from the
93-
---buffer's last line. Reading a sticky flag (rather than the live viewport
94-
---position) lets callers like `renderer.scroll_to_bottom()` that run *after*
95-
---a buffer write still return the correct answer even though the viewport has
96-
---not yet caught up to the newly appended lines.
85+
---Check if the cursor in the output window is at (or was at) the bottom of
86+
---the buffer, using the same logic as the original implementation.
87+
---Returns true if the window should continue auto-scrolling.
9788
---@param win? integer Window ID, defaults to state.windows.output_win
9889
---@return boolean
9990
function M.is_at_bottom(win)
@@ -116,18 +107,13 @@ function M.is_at_bottom(win)
116107
return true
117108
end
118109

119-
-- Prefer the sticky flag when it has been set by scroll/WinScrolled events.
120-
-- Fall back to a live viewport check on the very first call (flag is nil).
121-
if M._was_at_bottom_by_win[win] ~= nil then
122-
return M._was_at_bottom_by_win[win] == true
123-
end
124-
125-
local visible_bottom = M.get_visible_bottom_line(win)
126-
if not visible_bottom then
110+
local ok2, cursor = pcall(vim.api.nvim_win_get_cursor, win)
111+
if not ok2 then
127112
return true
128113
end
129114

130-
return visible_bottom >= line_count
115+
local prev_line_count = M._prev_line_count_by_win[win] or line_count
116+
return cursor[1] >= prev_line_count or cursor[1] >= line_count
131117
end
132118

133119
---@param win? integer
@@ -146,11 +132,13 @@ function M.reset_scroll_tracking(win)
146132
if win then
147133
M._last_visible_bottom_by_win[win] = nil
148134
M._was_at_bottom_by_win[win] = nil
135+
M._prev_line_count_by_win[win] = nil
149136
return
150137
end
151138

152139
M._last_visible_bottom_by_win = {}
153140
M._was_at_bottom_by_win = {}
141+
M._prev_line_count_by_win = {}
154142
end
155143

156144
---@param win? integer
@@ -174,12 +162,6 @@ function M.sync_cursor_with_viewport(win)
174162
end
175163

176164
M._last_visible_bottom_by_win[win] = visible_bottom
177-
178-
-- Update the sticky at-bottom flag based on whether the viewport now shows
179-
-- the last line. This is the key mechanism: when the user scrolls up (mouse
180-
-- or keyboard), WinScrolled fires here and clears the flag so that the next
181-
-- `is_at_bottom()` call returns false and streaming stops following the tail.
182-
M._was_at_bottom_by_win[win] = visible_bottom >= line_count
183165
end
184166

185167
---@param windows OpencodeWindowState

lua/opencode/ui/renderer/scroll.lua

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
local config = require('opencode.config')
21
local state = require('opencode.state')
32
local output_window = require('opencode.ui.output_window')
43

@@ -15,8 +14,6 @@ function M.get_output_win()
1514
end
1615

1716
---Move the cursor in `win` to the last line of `buf` and scroll so it's visible.
18-
---Also marks the window as "at bottom" so that the next is_at_bottom() call
19-
---returns true even when the buffer grew past the current viewport.
2017
---@param win integer
2118
---@param buf integer
2219
function M.scroll_win_to_bottom(win, buf)
@@ -29,7 +26,7 @@ function M.scroll_win_to_bottom(win, buf)
2926
vim.api.nvim_win_call(win, function()
3027
vim.cmd('normal! zb')
3128
end)
32-
output_window._was_at_bottom_by_win[win] = true
29+
output_window._prev_line_count_by_win[win] = line_count
3330
end
3431

3532
---@param buf integer|nil
@@ -44,6 +41,13 @@ function M.pre_flush(buf)
4441
return nil
4542
end
4643

44+
-- Snapshot the current line count before the buffer write so that
45+
-- is_at_bottom() can compare cursor position against it after the write.
46+
local ok, line_count = pcall(vim.api.nvim_buf_line_count, buf)
47+
if ok and line_count and line_count > 0 then
48+
output_window._prev_line_count_by_win[win] = line_count
49+
end
50+
4751
return {
4852
win = win,
4953
follow = output_window.is_at_bottom(win),

tests/unit/cursor_tracking_spec.lua

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,9 @@ describe('output_window.is_at_bottom', function()
220220
assert.is_true(output_window.is_at_bottom(win))
221221
end)
222222

223-
it('returns false when _was_at_bottom_by_win flag is explicitly false', function()
224-
-- Simulate user having scrolled away: flag is set to false
225-
output_window._was_at_bottom_by_win[win] = false
223+
it('returns false when cursor is not on last line', function()
224+
-- cursor not at last line
225+
vim.api.nvim_win_set_cursor(win, { 25, 0 })
226226
assert.is_false(output_window.is_at_bottom(win))
227227
end)
228228

@@ -232,16 +232,13 @@ describe('output_window.is_at_bottom', function()
232232
end)
233233

234234
it('returns false when user has scrolled viewport away from bottom', function()
235-
-- Simulate scrolling to bottom then user scrolling away
235+
-- Simulate scrolling to bottom then user pressing k to move cursor up
236236
local scroll = require('opencode.ui.renderer.scroll')
237237
scroll.scroll_win_to_bottom(win, buf)
238238
assert.is_true(output_window.is_at_bottom(win))
239239

240-
-- Simulate WinScrolled: user scrolls viewport up
241-
pcall(vim.api.nvim_win_call, win, function()
242-
vim.fn.winrestview({ topline = 1 })
243-
end)
244-
output_window.sync_cursor_with_viewport(win)
240+
-- User presses k: cursor moves away from the last line
241+
vim.api.nvim_win_set_cursor(win, { 40, 0 })
245242
assert.is_false(output_window.is_at_bottom(win))
246243
end)
247244

@@ -278,20 +275,14 @@ describe('output_window.is_at_bottom', function()
278275
pcall(vim.api.nvim_buf_delete, empty_buf, { force = true })
279276
end)
280277

281-
it('viewport-based: scrolling viewport up stops auto-scroll even when cursor stays at last line', function()
282-
-- Scroll to bottom so _was_at_bottom_by_win is set to true
278+
it('cursor-based: moving cursor up stops auto-scroll', function()
279+
-- Scroll to bottom: cursor is at last line
283280
local scroll = require('opencode.ui.renderer.scroll')
284281
scroll.scroll_win_to_bottom(win, buf)
285282
assert.is_true(output_window.is_at_bottom(win))
286283

287-
-- Scroll the viewport up without touching the cursor.
288-
-- WinScrolled fires → sync_cursor_with_viewport → _was_at_bottom_by_win = false
289-
pcall(vim.api.nvim_win_call, win, function()
290-
vim.fn.winrestview({ topline = 1 })
291-
end)
292-
output_window.sync_cursor_with_viewport(win)
293-
294-
-- Even though cursor is still at line 50, viewport has scrolled away
284+
-- User presses k: cursor moves away from last line → auto-scroll stops
285+
vim.api.nvim_win_set_cursor(win, { 40, 0 })
295286
assert.is_false(output_window.is_at_bottom(win))
296287
end)
297288

@@ -352,20 +343,12 @@ describe('output_window.sync_cursor_with_viewport', function()
352343
state.ui.set_windows(nil)
353344
end)
354345

355-
it('sets _was_at_bottom_by_win to false when viewport scrolls away from bottom', function()
356-
-- Start with viewport and cursor at last line
357-
vim.api.nvim_win_set_cursor(win, { 5, 0 })
358-
local scroll = require('opencode.ui.renderer.scroll')
359-
scroll.scroll_win_to_bottom(win, buf)
360-
assert.is_true(output_window._was_at_bottom_by_win[win])
361-
362-
-- Scroll the viewport up (simulate mouse wheel scroll)
363-
pcall(vim.api.nvim_win_call, win, function()
364-
vim.fn.winrestview({ topline = 1 })
365-
end)
346+
it('does not affect is_at_bottom when cursor is away from bottom', function()
347+
-- cursor not at last line
348+
vim.api.nvim_win_set_cursor(win, { 2, 0 })
366349
output_window.sync_cursor_with_viewport(win)
367350

368-
assert.is_false(output_window._was_at_bottom_by_win[win])
351+
assert.is_false(output_window.is_at_bottom(win))
369352
end)
370353

371354
it('does not move the cursor when the user is already reading earlier content', function()

0 commit comments

Comments
 (0)