Skip to content

Commit 285b698

Browse files
committed
fix(output_window): improve bottom-tracking with wrap-aware scroll and trailing blank line handling
This should smooth out the scroll jumps when streaming tokens near a fold
1 parent 638632c commit 285b698

4 files changed

Lines changed: 231 additions & 8 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,37 @@ function M.is_at_bottom(win)
141141
return true
142142
end
143143

144+
local effective_bottom = M.get_effective_bottom_line(state.windows.output_buf, line_count)
145+
144146
local ok2, cursor = pcall(vim.api.nvim_win_get_cursor, win)
145147
if not ok2 then
146148
return true
147149
end
148150

149151
local prev_line_count = M._prev_line_count_by_win[win] or line_count
150-
return cursor[1] >= prev_line_count or cursor[1] >= line_count
152+
local prev_effective_bottom = M.get_effective_bottom_line(state.windows.output_buf, prev_line_count)
153+
return cursor[1] >= prev_effective_bottom or cursor[1] >= effective_bottom
154+
end
155+
156+
---@param buf integer
157+
---@param line_count? integer
158+
---@return integer
159+
function M.get_effective_bottom_line(buf, line_count)
160+
if not buf or not vim.api.nvim_buf_is_valid(buf) then
161+
return 0
162+
end
163+
164+
line_count = line_count or vim.api.nvim_buf_line_count(buf)
165+
if not line_count or line_count <= 0 then
166+
return 0
167+
end
168+
169+
local last_line = vim.api.nvim_buf_get_lines(buf, line_count - 1, line_count, false)[1]
170+
if line_count > 1 and last_line == '' then
171+
return line_count - 1
172+
end
173+
174+
return line_count
151175
end
152176

153177
---@param win? integer
@@ -219,6 +243,7 @@ function M.setup(windows)
219243
)
220244
window_options.set_window_option('wrap', true, windows.output_win, { save_original = true })
221245
window_options.set_window_option('linebreak', true, windows.output_win, { save_original = true })
246+
pcall(window_options.set_window_option, 'smoothscroll', true, windows.output_win, { save_original = true })
222247
window_options.set_window_option('cursorline', false, windows.output_win, { save_original = true })
223248
window_options.set_window_option('number', false, windows.output_win, { save_original = true })
224249
window_options.set_window_option('relativenumber', false, windows.output_win, { save_original = true })
@@ -363,10 +388,11 @@ function M.set_folds(fold_ranges)
363388
end
364389

365390
local was_open = M.get_open_fold_starts(win, buf)
391+
local preserve_view = not M.is_at_bottom(win)
366392
vim.api.nvim_buf_set_var(buf, 'opencode_folds', folds)
367393

368394
vim.api.nvim_win_call(win, function()
369-
local view = vim.fn.winsaveview()
395+
local view = preserve_view and vim.fn.winsaveview() or nil
370396

371397
local line_count = vim.api.nvim_buf_line_count(buf)
372398
for _, range in ipairs(folds.ranges) do
@@ -381,7 +407,9 @@ function M.set_folds(fold_ranges)
381407
end
382408
end
383409

384-
vim.fn.winrestview(view)
410+
if view then
411+
vim.fn.winrestview(view)
412+
end
385413
end)
386414
end
387415

lua/opencode/ui/renderer/scroll.lua

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,67 @@ local output_window = require('opencode.ui.output_window')
33

44
local M = {}
55

6+
---@param win integer
7+
---@return boolean
8+
local function window_wraps(win)
9+
local ok, wrap = pcall(vim.api.nvim_get_option_value, 'wrap', { win = win })
10+
return ok and wrap == true or false
11+
end
12+
13+
---@param win integer
14+
---@return integer
15+
local function get_text_width(win)
16+
local width = vim.api.nvim_win_get_width(win)
17+
local ok, wininfo = pcall(vim.fn.getwininfo, win)
18+
local textoff = ok and wininfo and wininfo[1] and wininfo[1].textoff or 0
19+
return math.max(1, width - textoff)
20+
end
21+
22+
---@param buf integer
23+
---@param win integer
24+
---@param target_line integer
25+
---@return boolean
26+
local function end_of_target_line_fits_view(buf, win, target_line)
27+
if not window_wraps(win) then
28+
return true
29+
end
30+
31+
local visible_top = output_window.get_visible_top_line(win)
32+
local visible_bottom = output_window.get_visible_bottom_line(win)
33+
if not visible_top or not visible_bottom or target_line < visible_top or target_line > visible_bottom then
34+
return false
35+
end
36+
37+
local height = vim.api.nvim_win_get_height(win)
38+
local text_width = get_text_width(win)
39+
local rows = 0
40+
local line = visible_top
41+
42+
while line <= target_line do
43+
local fold_start = vim.api.nvim_win_call(win, function()
44+
return vim.fn.foldclosed(line)
45+
end)
46+
47+
if fold_start ~= -1 then
48+
rows = rows + 1
49+
line = vim.api.nvim_win_call(win, function()
50+
return vim.fn.foldclosedend(line) + 1
51+
end)
52+
else
53+
local text = vim.api.nvim_buf_get_lines(buf, line - 1, line, false)[1] or ''
54+
local display_width = math.max(1, vim.fn.strdisplaywidth(text))
55+
rows = rows + math.max(1, math.ceil(display_width / text_width))
56+
line = line + 1
57+
end
58+
59+
if rows > height then
60+
return false
61+
end
62+
end
63+
64+
return true
65+
end
66+
667
---@return integer|nil
768
function M.get_output_win()
869
local windows = state.windows
@@ -21,11 +82,27 @@ function M.scroll_win_to_bottom(win, buf)
2182
if line_count == 0 then
2283
return
2384
end
24-
local last_line = vim.api.nvim_buf_get_lines(buf, line_count - 1, line_count, false)[1] or ''
25-
vim.api.nvim_win_set_cursor(win, { line_count, #last_line })
26-
vim.api.nvim_win_call(win, function()
27-
vim.cmd('normal! zb')
28-
end)
85+
86+
local target_line = output_window.get_effective_bottom_line(buf, line_count)
87+
if target_line <= 0 then
88+
return
89+
end
90+
91+
local target_text = vim.api.nvim_buf_get_lines(buf, target_line - 1, target_line, false)[1] or ''
92+
local visible_bottom = output_window.get_visible_bottom_line(win)
93+
vim.api.nvim_win_set_cursor(win, { target_line, #target_text })
94+
95+
local needs_bottom_align = not visible_bottom or target_line > visible_bottom
96+
if not needs_bottom_align and window_wraps(win) then
97+
needs_bottom_align = not end_of_target_line_fits_view(buf, win, target_line)
98+
end
99+
100+
if needs_bottom_align then
101+
vim.api.nvim_win_call(win, function()
102+
vim.cmd('normal! zb')
103+
end)
104+
end
105+
29106
output_window._prev_line_count_by_win[win] = line_count
30107
end
31108

tests/unit/cursor_tracking_spec.lua

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

223+
it('treats a trailing blank line as padding, not content bottom', function()
224+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', '' })
225+
vim.api.nvim_win_set_cursor(win, { 2, 0 })
226+
227+
assert.is_true(output_window.is_at_bottom(win))
228+
end)
229+
223230
it('returns false when cursor is not on last line', function()
224231
-- cursor not at last line
225232
vim.api.nvim_win_set_cursor(win, { 25, 0 })
@@ -364,6 +371,7 @@ describe('renderer.scroll_to_bottom', function()
364371
local renderer = require('opencode.ui.renderer')
365372
local ctx = require('opencode.ui.renderer.ctx')
366373
local output_window = require('opencode.ui.output_window')
374+
local stub = require('luassert.stub')
367375
local buf, win
368376

369377
before_each(function()
@@ -437,6 +445,90 @@ describe('renderer.scroll_to_bottom', function()
437445
assert.equals(3, cursor[1])
438446
assert.equals(#longer_line - 1, cursor[2])
439447
end)
448+
449+
it('scrolls to the last non-empty line when buffer ends with padding', function()
450+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', '' })
451+
452+
local scroll = require('opencode.ui.renderer.scroll')
453+
scroll.scroll_win_to_bottom(win, buf)
454+
455+
local cursor = vim.api.nvim_win_get_cursor(win)
456+
assert.equals(2, cursor[1])
457+
end)
458+
459+
it('skips zb when the followed bottom line is already visible', function()
460+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', 'line 3' })
461+
vim.api.nvim_win_set_height(win, 10)
462+
vim.api.nvim_set_option_value('wrap', false, { win = win, scope = 'local' })
463+
vim.api.nvim_win_set_cursor(win, { 1, 0 })
464+
465+
local cmd_stub = stub(vim, 'cmd')
466+
local scroll = require('opencode.ui.renderer.scroll')
467+
scroll.scroll_win_to_bottom(win, buf)
468+
469+
local cursor = vim.api.nvim_win_get_cursor(win)
470+
assert.equals(3, cursor[1])
471+
assert.stub(cmd_stub).was_not_called_with('normal! zb')
472+
cmd_stub:revert()
473+
end)
474+
475+
it('uses zb when the followed bottom line is below the viewport', function()
476+
local lines = {}
477+
for i = 1, 40 do
478+
lines[i] = 'line ' .. i
479+
end
480+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
481+
vim.api.nvim_win_set_height(win, 5)
482+
vim.api.nvim_win_set_cursor(win, { 1, 0 })
483+
484+
local cmd_stub = stub(vim, 'cmd').invokes(function(cmd)
485+
if cmd == 'normal! zb' then
486+
vim.api.nvim_win_call(win, function()
487+
vim.fn.winrestview({ topline = 36 })
488+
end)
489+
return
490+
end
491+
return vim.api.nvim_cmd(vim.api.nvim_parse_cmd(cmd, {}), {})
492+
end)
493+
494+
local scroll = require('opencode.ui.renderer.scroll')
495+
scroll.scroll_win_to_bottom(win, buf)
496+
497+
local cursor = vim.api.nvim_win_get_cursor(win)
498+
assert.equals(40, cursor[1])
499+
assert.stub(cmd_stub).was_called_with('normal! zb')
500+
cmd_stub:revert()
501+
end)
502+
503+
it('uses zb when a wrapped bottom line grows past the last screen row', function()
504+
local long_line = string.rep('x', 80)
505+
local longer_line = string.rep('x', 180)
506+
507+
vim.api.nvim_win_set_width(win, 20)
508+
vim.api.nvim_win_set_height(win, 5)
509+
vim.api.nvim_set_option_value('wrap', true, { win = win, scope = 'local' })
510+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { long_line })
511+
512+
local cmd_stub = stub(vim, 'cmd').invokes(function(cmd)
513+
if cmd == 'normal! zb' then
514+
return
515+
end
516+
return vim.api.nvim_cmd(vim.api.nvim_parse_cmd(cmd, {}), {})
517+
end)
518+
519+
local scroll = require('opencode.ui.renderer.scroll')
520+
scroll.scroll_win_to_bottom(win, buf)
521+
cmd_stub:clear()
522+
523+
vim.api.nvim_buf_set_lines(buf, 0, 1, false, { longer_line })
524+
scroll.scroll_win_to_bottom(win, buf)
525+
526+
local cursor = vim.api.nvim_win_get_cursor(win)
527+
assert.equals(1, cursor[1])
528+
assert.equals(#longer_line - 1, cursor[2])
529+
assert.stub(cmd_stub).was_called_with('normal! zb')
530+
cmd_stub:revert()
531+
end)
440532
end)
441533

442534
describe('ui.focus_input', function()

tests/unit/output_window_spec.lua

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,32 @@ describe('output_window.setup', function()
175175
assert.equals(1, foldclosed)
176176
end)
177177

178+
it('does not restore the view when following the bottom during fold updates', function()
179+
output_window.setup({ output_buf = buf, output_win = win })
180+
output_window.set_lines({ 'a', 'b', 'c', 'd' })
181+
vim.api.nvim_win_set_cursor(win, { 4, 0 })
182+
183+
local winrestview_stub = stub(vim.fn, 'winrestview')
184+
185+
output_window.set_folds({ { from = 1, to = 3 } })
186+
187+
assert.stub(winrestview_stub).was_not_called()
188+
winrestview_stub:revert()
189+
end)
190+
191+
it('restores the view when the user is reading away from the bottom', function()
192+
output_window.setup({ output_buf = buf, output_win = win })
193+
output_window.set_lines({ 'a', 'b', 'c', 'd' })
194+
vim.api.nvim_win_set_cursor(win, { 2, 0 })
195+
196+
local winrestview_stub = stub(vim.fn, 'winrestview')
197+
198+
output_window.set_folds({ { from = 1, to = 3 } })
199+
200+
assert.stub(winrestview_stub).was_called()
201+
winrestview_stub:revert()
202+
end)
203+
178204
it('stores fold metadata in a lookup-friendly structure', function()
179205
output_window.setup({ output_buf = buf, output_win = win })
180206
output_window.set_folds({ { from = 3, to = 5 }, { from = 1, to = 2 } })

0 commit comments

Comments
 (0)