From 2168145d832a74f567c3950686d02afd01aaf0b7 Mon Sep 17 00:00:00 2001 From: Jensen Date: Sat, 14 Feb 2026 23:30:00 +0800 Subject: [PATCH 1/2] fix(renderer): debounce renders, refactor scroll logic, track scroll intent Three related improvements to output rendering: 1. Debounce concurrent render_full_session calls Problem: Rapid session switches trigger multiple redundant renders. Solution: Coalesce into one in-flight render + trailing render with latest options. 2. Refactor scroll_to_bottom conditions Convert nested if-else chain to declarative condition table for better readability and maintainability. 3. Add WinScrolled autocmd Track user scroll position to accurately detect scroll intent (whether user is at bottom or has scrolled up). --- lua/opencode/ui/output_window.lua | 34 +++++++++ lua/opencode/ui/renderer.lua | 105 +++++++++++++++++++++------- tests/unit/cursor_tracking_spec.lua | 82 +++++++++++++++++++++- tests/unit/renderer_spec.lua | 56 +++++++++++++++ 4 files changed, 250 insertions(+), 27 deletions(-) create mode 100644 tests/unit/renderer_spec.lua diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 764b5bff..8509d0eb 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -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() diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 28dc154c..02140487 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -9,6 +9,8 @@ local RenderState = require('opencode.ui.render_state') local M = { _prev_line_count = 0, _render_state = RenderState.new(), + _full_render_in_flight = nil, + _full_render_pending_opts = nil, _last_part_formatted = { part_id = nil, formatted_data = nil --[[@as Output|nil]], @@ -39,6 +41,7 @@ end, config.ui.output.rendering.markdown_debounce_ms or 250) function M.reset() M._prev_line_count = 0 M._render_state:reset() + M._full_render_pending_opts = nil M._last_part_formatted = { part_id = nil, formatted_data = nil } output_window.clear() @@ -122,17 +125,50 @@ local function fetch_session() return require('opencode.session').get_messages(session) end ----Request all of the session data from the opencode server and render it +---@param opts? {force_scroll?: boolean} ---@return Promise -function M.render_full_session() +function M.render_full_session(opts) + opts = opts or {} + if not output_window.mounted() or not state.api_client then return Promise.new():resolve(nil) end - return fetch_session():and_then(M._render_full_session_data) + if M._full_render_in_flight then + M._full_render_pending_opts = M._full_render_pending_opts or {} + M._full_render_pending_opts.force_scroll = M._full_render_pending_opts.force_scroll + or (opts.force_scroll == true) + return M._full_render_in_flight + end + + local render_promise = fetch_session():and_then(function(session_data) + return M._render_full_session_data(session_data, opts) + end) + + M._full_render_in_flight = render_promise + + render_promise:finally(function() + M._full_render_in_flight = nil + + local pending_opts = M._full_render_pending_opts + if pending_opts then + M._full_render_pending_opts = nil + vim.schedule(function() + if output_window.mounted() and state.api_client then + M.render_full_session(pending_opts) + end + end) + end + end) + + return render_promise end -function M._render_full_session_data(session_data, prev_revert, revert) +---@param session_data table +---@param opts? {force_scroll?: boolean} +function M._render_full_session_data(session_data, opts) + opts = opts or {} + session_data = session_data or {} M.reset() if not state.active_session or not state.messages then @@ -164,7 +200,7 @@ function M._render_full_session_data(session_data, prev_revert, revert) if set_mode_from_messages then M._set_model_and_mode_from_messages() end - M.scroll_to_bottom(true) + M.scroll_to_bottom(opts.force_scroll == true) if config.hooks and config.hooks.on_session_loaded then pcall(config.hooks.on_session_loaded, state.active_session) @@ -299,15 +335,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 @@ -319,28 +359,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 diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 38510e05..0bd85d2c 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -8,6 +8,85 @@ 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 }) @@ -176,13 +255,10 @@ describe('output_window.is_at_bottom', function() vim.api.nvim_win_set_cursor(win, { 50, 0 }) assert.is_true(output_window.is_at_bottom(win)) - -- Scroll viewport up via winrestview, cursor stays at line 50 pcall(vim.api.nvim_win_call, win, function() vim.fn.winrestview({ topline = 1 }) end) - -- Cursor is still at 50, so is_at_bottom should still be true - -- This is the key behavioral difference from viewport-based check assert.is_true(output_window.is_at_bottom(win)) end) end) diff --git a/tests/unit/renderer_spec.lua b/tests/unit/renderer_spec.lua new file mode 100644 index 00000000..c39b4e89 --- /dev/null +++ b/tests/unit/renderer_spec.lua @@ -0,0 +1,56 @@ +local Promise = require('opencode.promise') +local renderer = require('opencode.ui.renderer') +local session = require('opencode.session') +local output_window = require('opencode.ui.output_window') +local state = require('opencode.state') +local stub = require('luassert.stub') + +describe('renderer full session lifecycle', function() + before_each(function() + state.api_client = {} + state.active_session = { id = 'sess-1' } + renderer._full_render_in_flight = nil + renderer._full_render_pending_opts = nil + end) + + after_each(function() + if output_window.mounted and output_window.mounted.revert then + output_window.mounted:revert() + end + if session.get_messages and session.get_messages.revert then + session.get_messages:revert() + end + if renderer._render_full_session_data and renderer._render_full_session_data.revert then + renderer._render_full_session_data:revert() + end + end) + + it('coalesces concurrent full renders into one in-flight request', function() + local first_request = Promise.new() + local request_calls = 0 + + stub(output_window, 'mounted').returns(true) + stub(session, 'get_messages').invokes(function() + request_calls = request_calls + 1 + if request_calls == 1 then + return first_request + end + return Promise.new():resolve({}) + end) + stub(renderer, '_render_full_session_data').returns(nil) + + local p1 = renderer.render_full_session() + local p2 = renderer.render_full_session() + + assert.are.equal(1, request_calls) + assert.are.same(p1, p2) + + first_request:resolve({}) + + local ran_follow_up = vim.wait(200, function() + return request_calls == 2 and renderer._full_render_in_flight == nil + end) + + assert.is_true(ran_follow_up) + end) +end) From aebd46193f92d3f3aa96ac8cf68a369aa84759ac Mon Sep 17 00:00:00 2001 From: Jensen Date: Wed, 18 Feb 2026 00:43:57 +0800 Subject: [PATCH 2/2] adjust --- lua/opencode/ui/renderer.lua | 46 +++--------------------- tests/unit/cursor_tracking_spec.lua | 51 +++++++++++++++++++++----- tests/unit/renderer_spec.lua | 56 ----------------------------- 3 files changed, 48 insertions(+), 105 deletions(-) delete mode 100644 tests/unit/renderer_spec.lua diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 02140487..be059c5f 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -9,8 +9,6 @@ local RenderState = require('opencode.ui.render_state') local M = { _prev_line_count = 0, _render_state = RenderState.new(), - _full_render_in_flight = nil, - _full_render_pending_opts = nil, _last_part_formatted = { part_id = nil, formatted_data = nil --[[@as Output|nil]], @@ -41,7 +39,6 @@ end, config.ui.output.rendering.markdown_debounce_ms or 250) function M.reset() M._prev_line_count = 0 M._render_state:reset() - M._full_render_pending_opts = nil M._last_part_formatted = { part_id = nil, formatted_data = nil } output_window.clear() @@ -125,50 +122,17 @@ local function fetch_session() return require('opencode.session').get_messages(session) end ----@param opts? {force_scroll?: boolean} +---Request all of the session data from the opencode server and render it ---@return Promise -function M.render_full_session(opts) - opts = opts or {} - +function M.render_full_session() if not output_window.mounted() or not state.api_client then return Promise.new():resolve(nil) end - if M._full_render_in_flight then - M._full_render_pending_opts = M._full_render_pending_opts or {} - M._full_render_pending_opts.force_scroll = M._full_render_pending_opts.force_scroll - or (opts.force_scroll == true) - return M._full_render_in_flight - end - - local render_promise = fetch_session():and_then(function(session_data) - return M._render_full_session_data(session_data, opts) - end) - - M._full_render_in_flight = render_promise - - render_promise:finally(function() - M._full_render_in_flight = nil - - local pending_opts = M._full_render_pending_opts - if pending_opts then - M._full_render_pending_opts = nil - vim.schedule(function() - if output_window.mounted() and state.api_client then - M.render_full_session(pending_opts) - end - end) - end - end) - - return render_promise + return fetch_session():and_then(M._render_full_session_data) end ----@param session_data table ----@param opts? {force_scroll?: boolean} -function M._render_full_session_data(session_data, opts) - opts = opts or {} - session_data = session_data or {} +function M._render_full_session_data(session_data, prev_revert, revert) M.reset() if not state.active_session or not state.messages then @@ -200,7 +164,7 @@ function M._render_full_session_data(session_data, opts) if set_mode_from_messages then M._set_model_and_mode_from_messages() end - M.scroll_to_bottom(opts.force_scroll == true) + M.scroll_to_bottom(true) if config.hooks and config.hooks.on_session_loaded then pcall(config.hooks.on_session_loaded, state.active_session) diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 0bd85d2c..9b3e18a8 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -31,7 +31,11 @@ describe('cursor persistence (state)', function() }) 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 } @@ -162,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 }) @@ -190,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 } @@ -241,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 } @@ -255,10 +271,13 @@ describe('output_window.is_at_bottom', function() vim.api.nvim_win_set_cursor(win, { 50, 0 }) assert.is_true(output_window.is_at_bottom(win)) + -- Scroll viewport up via winrestview, cursor stays at line 50 pcall(vim.api.nvim_win_call, win, function() vim.fn.winrestview({ topline = 1 }) end) + -- Cursor is still at 50, so is_at_bottom should still be true + -- This is the key behavioral difference from viewport-based check assert.is_true(output_window.is_at_bottom(win)) end) end) @@ -278,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 } @@ -327,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 = { @@ -373,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 } diff --git a/tests/unit/renderer_spec.lua b/tests/unit/renderer_spec.lua deleted file mode 100644 index c39b4e89..00000000 --- a/tests/unit/renderer_spec.lua +++ /dev/null @@ -1,56 +0,0 @@ -local Promise = require('opencode.promise') -local renderer = require('opencode.ui.renderer') -local session = require('opencode.session') -local output_window = require('opencode.ui.output_window') -local state = require('opencode.state') -local stub = require('luassert.stub') - -describe('renderer full session lifecycle', function() - before_each(function() - state.api_client = {} - state.active_session = { id = 'sess-1' } - renderer._full_render_in_flight = nil - renderer._full_render_pending_opts = nil - end) - - after_each(function() - if output_window.mounted and output_window.mounted.revert then - output_window.mounted:revert() - end - if session.get_messages and session.get_messages.revert then - session.get_messages:revert() - end - if renderer._render_full_session_data and renderer._render_full_session_data.revert then - renderer._render_full_session_data:revert() - end - end) - - it('coalesces concurrent full renders into one in-flight request', function() - local first_request = Promise.new() - local request_calls = 0 - - stub(output_window, 'mounted').returns(true) - stub(session, 'get_messages').invokes(function() - request_calls = request_calls + 1 - if request_calls == 1 then - return first_request - end - return Promise.new():resolve({}) - end) - stub(renderer, '_render_full_session_data').returns(nil) - - local p1 = renderer.render_full_session() - local p2 = renderer.render_full_session() - - assert.are.equal(1, request_calls) - assert.are.same(p1, p2) - - first_request:resolve({}) - - local ran_follow_up = vim.wait(200, function() - return request_calls == 2 and renderer._full_render_in_flight == nil - end) - - assert.is_true(ran_follow_up) - end) -end)