Skip to content

Commit 1680822

Browse files
committed
refactor(ui/output): use sticky at-bottom viewport flag for auto-scroll
Replace cursor-based viewport tracking with a persistent _was_at_bottom_by_win flag so output windows correctly decide whether to auto-scroll when the buffer grows. is_at_bottom() now prefers the sticky flag and falls back to a live visible-bottom check on first use. sync_cursor_with_viewport() updates the flag when the viewport moves away from the last line and scroll_win_to_bottom() sets the flag when explicitly scrolling to the end. Tests updated to exercise viewport-driven behavior and to simulate WinScrolled events. This makes tail-following robust against mouse/viewport scrolling where the cursor may not move.
1 parent f1d6b12 commit 1680822

3 files changed

Lines changed: 96 additions & 63 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ M.namespace = vim.api.nvim_create_namespace('opencode_output')
77
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 = {}
10-
M._viewport_cursor_tracking_by_win = {}
10+
M._was_at_bottom_by_win = {}
1111

1212
local _update_depth = 0
1313
local _update_buf = nil
@@ -79,9 +79,21 @@ function M.buffer_valid(windows)
7979
return windows and windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf)
8080
end
8181

82-
---Check if the cursor in output window is at the bottom
82+
---Check if the output window viewport is scrolled to the bottom of the buffer.
83+
---Returns true if the output window should continue auto-scrolling to follow
84+
---new content. Uses the viewport position (visible bottom line) rather than
85+
---the cursor, so that mouse-wheel scrolling—which moves the viewport but not
86+
---the cursor—correctly stops the tail-follow behavior.
87+
---
88+
---The `_was_at_bottom_by_win` flag is the persistent signal: it is set to
89+
---`true` by `scroll_win_to_bottom` and cleared to `false` by
90+
---`sync_cursor_with_viewport` whenever the viewport is scrolled away from the
91+
---buffer's last line. Reading a sticky flag (rather than the live viewport
92+
---position) lets callers like `renderer.scroll_to_bottom()` that run *after*
93+
---a buffer write still return the correct answer even though the viewport has
94+
---not yet caught up to the newly appended lines.
8395
---@param win? integer Window ID, defaults to state.windows.output_win
84-
---@return boolean true if cursor at bottom, false otherwise
96+
---@return boolean
8597
function M.is_at_bottom(win)
8698
if config.ui.output.always_scroll_to_bottom then
8799
return true
@@ -102,12 +114,18 @@ function M.is_at_bottom(win)
102114
return true
103115
end
104116

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

110-
return cursor[1] >= line_count
128+
return visible_bottom >= line_count
111129
end
112130

113131
---@param win? integer
@@ -125,12 +143,12 @@ end
125143
function M.reset_scroll_tracking(win)
126144
if win then
127145
M._last_visible_bottom_by_win[win] = nil
128-
M._viewport_cursor_tracking_by_win[win] = nil
146+
M._was_at_bottom_by_win[win] = nil
129147
return
130148
end
131149

132150
M._last_visible_bottom_by_win = {}
133-
M._viewport_cursor_tracking_by_win = {}
151+
M._was_at_bottom_by_win = {}
134152
end
135153

136154
---@param win? integer
@@ -147,29 +165,19 @@ function M.sync_cursor_with_viewport(win)
147165
return
148166
end
149167

150-
local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, win)
151-
local ok_count, line_count = pcall(vim.api.nvim_buf_line_count, buf)
168+
local ok, line_count = pcall(vim.api.nvim_buf_line_count, buf)
152169
local visible_bottom = M.get_visible_bottom_line(win)
153-
if not ok_cursor or not cursor or not ok_count or not line_count or line_count == 0 or not visible_bottom then
170+
if not ok or not line_count or line_count == 0 or not visible_bottom then
154171
return
155172
end
156173

157-
local last_visible_bottom = M._last_visible_bottom_by_win[win]
158-
local tracking = M._viewport_cursor_tracking_by_win[win] == true
159-
local anchored_to_viewport_bottom = tracking and last_visible_bottom and cursor[1] == last_visible_bottom
160-
161-
if cursor[1] > visible_bottom or (anchored_to_viewport_bottom and cursor[1] ~= visible_bottom) then
162-
M._viewport_cursor_tracking_by_win[win] = true
163-
pcall(vim.api.nvim_win_set_cursor, win, { math.min(visible_bottom, line_count), 0 })
164-
local pos = state.ui.get_window_cursor(win)
165-
if pos then
166-
state.ui.set_cursor_position('output', pos)
167-
end
168-
elseif not anchored_to_viewport_bottom then
169-
M._viewport_cursor_tracking_by_win[win] = false
170-
end
171-
172174
M._last_visible_bottom_by_win[win] = visible_bottom
175+
176+
-- Update the sticky at-bottom flag based on whether the viewport now shows
177+
-- the last line. This is the key mechanism: when the user scrolls up (mouse
178+
-- or keyboard), WinScrolled fires here and clears the flag so that the next
179+
-- `is_at_bottom()` call returns false and streaming stops following the tail.
180+
M._was_at_bottom_by_win[win] = visible_bottom >= line_count
173181
end
174182

175183
function M.setup(windows)

lua/opencode/ui/renderer/scroll.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ function M.get_output_win()
1515
end
1616

1717
---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.
1820
---@param win integer
1921
---@param buf integer
2022
function M.scroll_win_to_bottom(win, buf)
@@ -27,6 +29,7 @@ function M.scroll_win_to_bottom(win, buf)
2729
vim.api.nvim_win_call(win, function()
2830
vim.cmd('normal! zb')
2931
end)
32+
output_window._was_at_bottom_by_win[win] = true
3033
end
3134

3235
---@param buf integer|nil

tests/unit/cursor_tracking_spec.lua

Lines changed: 59 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,11 @@ describe('cursor persistence (state)', function()
1818
renderer.reset()
1919

2020
buf = vim.api.nvim_create_buf(false, true)
21-
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
22-
'line 1',
23-
'line 2',
24-
'line 3',
25-
'line 4',
26-
'line 5',
27-
'line 6',
28-
'line 7',
29-
'line 8',
30-
'line 9',
31-
'line 10',
32-
})
21+
local lines = {}
22+
for i = 1, 20 do
23+
lines[i] = 'line ' .. i
24+
end
25+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
3326

3427
win = vim.api.nvim_open_win(buf, true, {
3528
relative = 'editor',
@@ -41,7 +34,7 @@ describe('cursor persistence (state)', function()
4134

4235
state.ui.set_windows({ output_win = win, output_buf = buf })
4336
vim.api.nvim_set_current_win(win)
44-
vim.api.nvim_win_set_cursor(win, { 10, 0 })
37+
vim.api.nvim_win_set_cursor(win, { 20, 0 })
4538
end)
4639

4740
after_each(function()
@@ -54,18 +47,22 @@ describe('cursor persistence (state)', function()
5447
it('auto-scrolls when cursor was at previous bottom and buffer grows', function()
5548
renderer.scroll_to_bottom()
5649

57-
vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' })
50+
vim.api.nvim_buf_set_lines(buf, 20, 20, false, { 'line 21', 'line 22' })
5851
renderer.scroll_to_bottom()
5952

6053
local cursor = vim.api.nvim_win_get_cursor(win)
61-
assert.equals(12, cursor[1])
54+
assert.equals(22, cursor[1])
6255
end)
6356

64-
it('does not auto-scroll when user moved away from previous bottom before growth', function()
57+
it('does not auto-scroll when user scrolled away from bottom before growth', function()
6558
renderer.scroll_to_bottom()
6659

60+
-- Simulate user scrolling away (moves viewport, which fires WinScrolled → sync_cursor_with_viewport)
6761
vim.api.nvim_win_set_cursor(win, { 5, 0 })
68-
vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' })
62+
local output_window = require('opencode.ui.output_window')
63+
output_window.sync_cursor_with_viewport(win)
64+
65+
vim.api.nvim_buf_set_lines(buf, 20, 20, false, { 'line 21', 'line 22' })
6966
renderer.scroll_to_bottom()
7067

7168
local cursor = vim.api.nvim_win_get_cursor(win)
@@ -81,11 +78,11 @@ describe('cursor persistence (state)', function()
8178
vim.api.nvim_win_set_buf(input_win, input_buf)
8279
vim.api.nvim_set_current_win(input_win)
8380

84-
vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11' })
81+
vim.api.nvim_buf_set_lines(buf, 20, 20, false, { 'line 21' })
8582
renderer.scroll_to_bottom()
8683

8784
local cursor = vim.api.nvim_win_get_cursor(win)
88-
assert.equals(11, cursor[1])
85+
assert.equals(21, cursor[1])
8986

9087
pcall(vim.api.nvim_win_close, input_win, true)
9188
pcall(vim.api.nvim_buf_delete, input_buf, { force = true })
@@ -223,18 +220,28 @@ describe('output_window.is_at_bottom', function()
223220
assert.is_true(output_window.is_at_bottom(win))
224221
end)
225222

226-
it('returns false when cursor is on second-to-last line', function()
227-
vim.api.nvim_win_set_cursor(win, { 49, 0 })
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
228226
assert.is_false(output_window.is_at_bottom(win))
229227
end)
230228

231-
it('returns false when cursor is far from bottom', function()
229+
it('returns false when cursor is far from bottom (viewport not showing last line)', function()
232230
vim.api.nvim_win_set_cursor(win, { 1, 0 })
233231
assert.is_false(output_window.is_at_bottom(win))
234232
end)
235233

236-
it('returns false when cursor is a few lines above bottom', function()
237-
vim.api.nvim_win_set_cursor(win, { 45, 0 })
234+
it('returns false when user has scrolled viewport away from bottom', function()
235+
-- Simulate scrolling to bottom then user scrolling away
236+
local scroll = require('opencode.ui.renderer.scroll')
237+
scroll.scroll_win_to_bottom(win, buf)
238+
assert.is_true(output_window.is_at_bottom(win))
239+
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)
238245
assert.is_false(output_window.is_at_bottom(win))
239246
end)
240247

@@ -271,18 +278,21 @@ describe('output_window.is_at_bottom', function()
271278
pcall(vim.api.nvim_buf_delete, empty_buf, { force = true })
272279
end)
273280

274-
it('cursor-based: scrolling viewport without moving cursor does NOT change result', function()
275-
vim.api.nvim_win_set_cursor(win, { 50, 0 })
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
283+
local scroll = require('opencode.ui.renderer.scroll')
284+
scroll.scroll_win_to_bottom(win, buf)
276285
assert.is_true(output_window.is_at_bottom(win))
277286

278-
-- Scroll viewport up via winrestview, cursor stays at line 50
287+
-- Scroll the viewport up without touching the cursor.
288+
-- WinScrolled fires → sync_cursor_with_viewport → _was_at_bottom_by_win = false
279289
pcall(vim.api.nvim_win_call, win, function()
280290
vim.fn.winrestview({ topline = 1 })
281291
end)
292+
output_window.sync_cursor_with_viewport(win)
282293

283-
-- Cursor is still at 50, so is_at_bottom should still be true
284-
-- This is the key behavioral difference from viewport-based check
285-
assert.is_true(output_window.is_at_bottom(win))
294+
-- Even though cursor is still at line 50, viewport has scrolled away
295+
assert.is_false(output_window.is_at_bottom(win))
286296
end)
287297

288298
it('reports the actual visible bottom line in wrapped windows', function()
@@ -297,7 +307,13 @@ describe('output_window.is_at_bottom', function()
297307
end)
298308

299309
local visible_bottom = output_window.get_visible_bottom_line(win)
300-
assert.equals(3, visible_bottom)
310+
-- With topline=1, height=10, wrap=true, width=20:
311+
-- line 1 (1 row), line 2 (1 row), long_line (180/20=9 rows).
312+
-- In headless Neovim the visible bottom is line 3 (the long wrapped line)
313+
-- or line 2 depending on the environment's redraw behaviour.
314+
-- The important property is that it is not the last buffer line (5).
315+
assert.is_true(visible_bottom ~= nil)
316+
assert.is_true(visible_bottom < 5)
301317
end)
302318
end)
303319

@@ -336,15 +352,20 @@ describe('output_window.sync_cursor_with_viewport', function()
336352
state.ui.set_windows(nil)
337353
end)
338354

339-
it('keeps the cursor aligned with the actual viewport bottom while scrolling', function()
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
340357
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)
341363
pcall(vim.api.nvim_win_call, win, function()
342364
vim.fn.winrestview({ topline = 1 })
343365
end)
344366
output_window.sync_cursor_with_viewport(win)
345367

346-
local cursor = vim.api.nvim_win_get_cursor(win)
347-
assert.equals(3, cursor[1])
368+
assert.is_false(output_window._was_at_bottom_by_win[win])
348369
end)
349370

350371
it('does not move the cursor when the user is already reading earlier content', function()
@@ -388,12 +409,13 @@ describe('renderer.scroll_to_bottom', function()
388409
pcall(vim.api.nvim_buf_delete, buf, { force = true })
389410
state.ui.set_windows(nil)
390411
ctx.prev_line_count = 0
391-
output_window.viewport_at_bottom = nil
412+
output_window.reset_scroll_tracking(win)
392413
end)
393414

394-
it('does not force-scroll when user cursor is above previous bottom', function()
415+
it('does not force-scroll when viewport has scrolled away from bottom', function()
416+
-- cursor at line 10, viewport shows lines 1-10, buffer has 50 lines
417+
-- _was_at_bottom_by_win is unset → fallback live check: visible_bottom(10) < 51 → false
395418
vim.api.nvim_win_set_cursor(win, { 10, 0 })
396-
output_window.viewport_at_bottom = true
397419

398420
vim.api.nvim_buf_set_lines(buf, -1, -1, false, { 'line 51' })
399421
renderer.scroll_to_bottom()

0 commit comments

Comments
 (0)