@@ -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 )
302318end )
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