Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions lua/opencode/ui/output_window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,40 @@ function M.setup_autocmds(windows, group)
state.save_cursor_position('output', windows.output_win)
end,
})

vim.api.nvim_create_autocmd('WinScrolled', {
group = group,
buffer = windows.output_buf,
callback = function()
if not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then
return
end

local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
if not ok then
return
end

local ok2, line_count = pcall(vim.api.nvim_buf_line_count, windows.output_buf)
if not ok2 or line_count == 0 then
return
end

if cursor[1] >= line_count then
local ok3, view = pcall(vim.api.nvim_win_call, windows.output_win, vim.fn.winsaveview)
if ok3 and type(view) == 'table' then
local topline = view.topline or 1
local win_height = vim.api.nvim_win_get_height(windows.output_win)
local visible_bottom = math.min(topline + win_height - 1, line_count)

if visible_bottom < line_count then
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { visible_bottom, 0 })
state.save_cursor_position('output', windows.output_win)
end
end
end
end,
})
end

function M.clear()
Expand Down
59 changes: 40 additions & 19 deletions lua/opencode/ui/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -299,15 +299,19 @@ end
---Respects cursor position if user has scrolled up
---@param force? boolean If true, scroll regardless of current position
function M.scroll_to_bottom(force)
if not state.windows or not state.windows.output_buf or not state.windows.output_win then
local windows = state.windows
local output_win = windows and windows.output_win
local output_buf = windows and windows.output_buf

if not output_buf or not output_win then
return
end

if not vim.api.nvim_win_is_valid(state.windows.output_win) then
if not vim.api.nvim_win_is_valid(output_win) then
return
end

local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf)
local ok, line_count = pcall(vim.api.nvim_buf_line_count, output_buf)
if not ok or line_count == 0 then
return
end
Expand All @@ -319,28 +323,45 @@ function M.scroll_to_bottom(force)

trigger_on_data_rendered()

-- Determine if we should scroll to bottom
local should_scroll = force == true
local scroll_conditions = {
{
name = 'force',
test = function()
return force == true
end,
},
{
name = 'first_render',
test = function()
return prev_line_count == 0
end,
},
{
name = 'always_scroll',
test = function()
return config.ui.output.always_scroll_to_bottom
end,
},
{
name = 'cursor_at_bottom',
test = function()
local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, output_win)
return ok_cursor and cursor and (cursor[1] >= prev_line_count or cursor[1] >= line_count)
end,
},
}

if not should_scroll then
-- Always scroll on initial render
if prev_line_count == 0 then
should_scroll = true
-- Respect explicit config to always follow output
elseif config.ui.output.always_scroll_to_bottom then
local should_scroll = false
for _, condition in ipairs(scroll_conditions) do
if condition.test() then
should_scroll = true
-- Scroll if user is at bottom (respects manual scroll position)
else
local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, state.windows.output_win)
local cursor_row = ok_cursor and cursor[1] or 1
should_scroll = cursor_row >= prev_line_count
break
end
end

if should_scroll then
vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 })
-- Use zb to position the cursor line at the bottom of the visible window
vim.api.nvim_win_call(state.windows.output_win, function()
vim.api.nvim_win_set_cursor(output_win, { line_count, 0 })
vim.api.nvim_win_call(output_win, function()
vim.cmd('normal! zb')
end)
end
Expand Down
125 changes: 118 additions & 7 deletions tests/unit/cursor_tracking_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,89 @@ describe('cursor persistence (state)', function()
state.set_cursor_position('output', nil)
end)

describe('renderer.scroll_to_bottom', function()
local renderer = require('opencode.ui.renderer')
local buf, win

before_each(function()
config.setup({})
renderer.reset()

buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
'line 1',
'line 2',
'line 3',
'line 4',
'line 5',
'line 6',
'line 7',
'line 8',
'line 9',
'line 10',
})

win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = 80,
height = 10,
row = 0,
col = 0,
})

state.windows = { output_win = win, output_buf = buf }
vim.api.nvim_set_current_win(win)
vim.api.nvim_win_set_cursor(win, { 10, 0 })
end)

after_each(function()
renderer.reset()
pcall(vim.api.nvim_win_close, win, true)
pcall(vim.api.nvim_buf_delete, buf, { force = true })
state.windows = nil
end)

it('auto-scrolls when cursor was at previous bottom and buffer grows', function()
renderer.scroll_to_bottom()

vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' })
renderer.scroll_to_bottom()

local cursor = vim.api.nvim_win_get_cursor(win)
assert.equals(12, cursor[1])
end)

it('does not auto-scroll when user moved away from previous bottom before growth', function()
renderer.scroll_to_bottom()

vim.api.nvim_win_set_cursor(win, { 5, 0 })
vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' })
renderer.scroll_to_bottom()

local cursor = vim.api.nvim_win_get_cursor(win)
assert.equals(5, cursor[1])
end)

it('auto-scrolls even when output window is unfocused if cursor was at previous bottom', function()
renderer.scroll_to_bottom()

local input_buf = vim.api.nvim_create_buf(false, true)
vim.cmd('vsplit')
local input_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(input_win, input_buf)
vim.api.nvim_set_current_win(input_win)

vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11' })
renderer.scroll_to_bottom()

local cursor = vim.api.nvim_win_get_cursor(win)
assert.equals(11, cursor[1])

pcall(vim.api.nvim_win_close, input_win, true)
pcall(vim.api.nvim_buf_delete, input_buf, { force = true })
end)
end)

describe('set/get round-trip', function()
it('stores and retrieves input cursor', function()
state.set_cursor_position('input', { 5, 3 })
Expand Down Expand Up @@ -83,7 +166,11 @@ describe('cursor persistence (state)', function()
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line1', 'line2', 'line3' })
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor', width = 40, height = 10, row = 0, col = 0,
relative = 'editor',
width = 40,
height = 10,
row = 0,
col = 0,
})
vim.api.nvim_win_set_cursor(win, { 2, 3 })

Expand Down Expand Up @@ -111,7 +198,11 @@ describe('output_window.is_at_bottom', function()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)

win = vim.api.nvim_open_win(buf, true, {
relative = 'editor', width = 80, height = 10, row = 0, col = 0,
relative = 'editor',
width = 80,
height = 10,
row = 0,
col = 0,
})

state.windows = { output_win = win, output_buf = buf }
Expand Down Expand Up @@ -162,7 +253,11 @@ describe('output_window.is_at_bottom', function()
it('returns true for empty buffer', function()
local empty_buf = vim.api.nvim_create_buf(false, true)
local empty_win = vim.api.nvim_open_win(empty_buf, true, {
relative = 'editor', width = 40, height = 5, row = 0, col = 0,
relative = 'editor',
width = 40,
height = 5,
row = 0,
col = 0,
})
state.windows = { output_win = empty_win, output_buf = empty_buf }

Expand Down Expand Up @@ -202,7 +297,11 @@ describe('renderer.scroll_to_bottom', function()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)

win = vim.api.nvim_open_win(buf, true, {
relative = 'editor', width = 80, height = 10, row = 0, col = 0,
relative = 'editor',
width = 80,
height = 10,
row = 0,
col = 0,
})

state.windows = { output_win = win, output_buf = buf }
Expand Down Expand Up @@ -251,10 +350,18 @@ describe('ui.focus_input', function()
vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'output' })

output_win = vim.api.nvim_open_win(output_buf, true, {
relative = 'editor', width = 40, height = 5, row = 0, col = 0,
relative = 'editor',
width = 40,
height = 5,
row = 0,
col = 0,
})
input_win = vim.api.nvim_open_win(input_buf, true, {
relative = 'editor', width = 40, height = 5, row = 6, col = 0,
relative = 'editor',
width = 40,
height = 5,
row = 6,
col = 0,
})

state.windows = {
Expand Down Expand Up @@ -297,7 +404,11 @@ describe('renderer._add_message_to_buffer scrolling', function()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'existing line' })

win = vim.api.nvim_open_win(buf, true, {
relative = 'editor', width = 80, height = 10, row = 0, col = 0,
relative = 'editor',
width = 80,
height = 10,
row = 0,
col = 0,
})

state.windows = { output_win = win, output_buf = buf }
Expand Down