Skip to content

Commit 494e474

Browse files
committed
fix(scroll): handle NUL bytes in wrapped output lines
1 parent 9a73395 commit 494e474

3 files changed

Lines changed: 58 additions & 8 deletions

File tree

‎lua/opencode/ui/output_window.lua‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@ function M.on_user_navigation(win)
215215
local previous_cursor = M._last_cursor_by_win[win]
216216
M._last_cursor_by_win[win] = cursor
217217
local bottom_text = vim.api.nvim_buf_get_lines(windows.output_buf, effective_bottom - 1, effective_bottom, false)[1] --[[@as string]]
218+
-- Avoid Blob conversion at the Vimscript boundary without shifting byte offsets.
219+
bottom_text = bottom_text:gsub('%z', '\n')
218220
local cursor_at_end = cursor_line >= effective_bottom and cursor[2] >= math.max(0, vim.fn.match(bottom_text, '.$'))
219221
local moved_toward_end = (previous_cursor and (cursor_line > previous_cursor[1] or cursor[2] > previous_cursor[2]))
220222
or (top and previous_top and top > previous_top)

‎lua/opencode/ui/renderer/scroll.lua‎

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

44
local M = {}
55

6+
---@param text string
7+
---@return integer
8+
local function line_display_width(text)
9+
-- Vimscript receives NUL-containing Lua strings as Blobs. Its string
10+
-- representation of a buffer NUL is a newline, with the same display width.
11+
local display_text = text:gsub('%z', '\n')
12+
return math.max(1, vim.fn.strdisplaywidth(display_text))
13+
end
14+
615
---@param win integer
716
---@return boolean
817
local function window_wraps(win)
@@ -38,7 +47,7 @@ local function get_bottom_aligned_topline(buf, win, target_line)
3847
line = fold_start - 1
3948
else
4049
local text = vim.api.nvim_buf_get_lines(buf, line - 1, line, false)[1] or ''
41-
local display_width = math.max(1, vim.fn.strdisplaywidth(text))
50+
local display_width = line_display_width(text)
4251
rows = rows + math.max(1, math.ceil(display_width / text_width))
4352
line = line - 1
4453
end
@@ -87,10 +96,7 @@ local function end_of_target_line_fits_view(buf, win, target_line)
8796
end)
8897
else
8998
local text = vim.api.nvim_buf_get_lines(buf, line - 1, line, false)[1] or ''
90-
if type(text) ~= 'string' then
91-
text = tostring(text)
92-
end
93-
local display_width = math.max(1, vim.fn.strdisplaywidth(text))
99+
local display_width = line_display_width(text)
94100
rows = rows + math.max(1, math.ceil(display_width / text_width))
95101
line = line + 1
96102
end
@@ -128,9 +134,6 @@ function M.scroll_win_to_bottom(win, buf)
128134
end
129135

130136
local target_text = vim.api.nvim_buf_get_lines(buf, target_line - 1, target_line, false)[1] or ''
131-
if type(target_text) ~= 'string' then
132-
target_text = tostring(target_text)
133-
end
134137
local visible_bottom = output_window.get_visible_bottom_line(win)
135138
vim.api.nvim_win_set_cursor(win, { target_line, #target_text })
136139
state.ui.set_cursor_position('output', { target_line, #target_text })

‎tests/unit/cursor_tracking_spec.lua‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,51 @@ describe('renderer.scroll_to_bottom', function()
579579
config.values.ui.output.always_scroll_to_bottom = false
580580
end)
581581

582+
it('follows visible binary output without converting NUL-containing strings to Blobs', function()
583+
local scroll = require('opencode.ui.renderer.scroll')
584+
local text = string.rep('x\0', 15)
585+
vim.api.nvim_win_set_width(win, 20)
586+
vim.api.nvim_win_set_height(win, 5)
587+
vim.api.nvim_set_option_value('wrap', true, { win = win })
588+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'header', text })
589+
vim.api.nvim_win_set_cursor(win, { 1, 0 })
590+
591+
assert.equals(2, output_window.get_visible_bottom_line(win))
592+
scroll.scroll_win_to_bottom(win, buf)
593+
assert.same({ 2, #text - 1 }, vim.api.nvim_win_get_cursor(win))
594+
595+
local snapshot = scroll.pre_flush(buf)
596+
assert.is_true(snapshot.follow)
597+
text = text .. string.rep('y\0', 10)
598+
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { text })
599+
scroll.post_flush(snapshot, buf)
600+
601+
assert.same({ 2, #text - 1 }, vim.api.nvim_win_get_cursor(win))
602+
assert.equals(text, vim.api.nvim_buf_get_lines(buf, 1, 2, false)[1])
603+
end)
604+
605+
it('bottom-aligns binary output using the display width of NUL bytes', function()
606+
local scroll = require('opencode.ui.renderer.scroll')
607+
local lines = {}
608+
for i = 1, 20 do
609+
lines[i] = 'line ' .. i
610+
end
611+
lines[19] = string.rep('x\0', 10)
612+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
613+
vim.api.nvim_win_set_width(win, 20)
614+
vim.api.nvim_win_set_height(win, 5)
615+
vim.api.nvim_set_option_value('wrap', true, { win = win })
616+
vim.api.nvim_win_set_cursor(win, { 1, 0 })
617+
618+
assert.is_true(output_window.get_visible_bottom_line(win) < 20)
619+
scroll.scroll_win_to_bottom(win, buf)
620+
621+
assert.equals(17, output_window.get_visible_top_line(win))
622+
assert.equals(20, output_window.get_visible_bottom_line(win))
623+
assert.equals(20, vim.api.nvim_win_get_cursor(win)[1])
624+
assert.equals(lines[19], vim.api.nvim_buf_get_lines(buf, 18, 19, false)[1])
625+
end)
626+
582627
it('moves to the visual end when the last wrapped line grows', function()
583628
local long_line = string.rep('x', 120)
584629
local longer_line = string.rep('x', 180)

0 commit comments

Comments
 (0)