From a05543b0cf4acf5808e95be2ca572767eaf3ec86 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Fri, 29 May 2026 13:57:25 +0800 Subject: [PATCH 1/3] perf(render): fix lazy-render and fold performance for large sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace cursor+zc fold creation with :{from},{to}fold Ex commands — avoids cursor-triggered screen redraws (~90ms/fold in large buffers) - Switch to foldmethod=manual and stay there — prevents foldexpr recalculation on every buffer line (~4.6s on 87K lines) - Lazy-render only viewport-sized message count on initial load - Load more messages on scroll-to-top via WinScrolled autocmd - Fix lazy_render_count being cleared by M.reset() — read before reset, persist back to ctx after determining limit - Debounce WinScrolled load_more callback (150ms) to prevent rapid re-renders during fast scrolling - Remove debug vim.notify logging from render paths Performance: ~36s → ~10ms render time on a 2786-msg/87K-line session. Closes #392 --- lua/opencode/ui/output_window.lua | 30 +++-- lua/opencode/ui/renderer.lua | 64 +++++++++ lua/opencode/ui/renderer/ctx.lua | 3 + tests/unit/renderer_lazy_spec.lua | 209 ++++++++++++++++++++++++++++++ 4 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 tests/unit/renderer_lazy_spec.lua diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 5bca0207..53cfbf62 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -404,31 +404,28 @@ function M.set_folds(fold_ranges) end local was_open = M.get_open_fold_starts(win, buf) - vim.api.nvim_buf_set_var(buf, 'opencode_folds', folds) vim.api.nvim_win_call(win, function() local view = vim.fn.winsaveview() - vim.cmd('silent! normal! zx') - local prev_starts = {} - for _, start_line in ipairs(prev_folds.starts) do - prev_starts[start_line] = true - end + -- manual avoids foldexpr recalculation on each fold operation + vim.api.nvim_set_option_value('foldmethod', 'manual', { win = 0 }) + + local line_count = vim.api.nvim_buf_line_count(buf) for _, range in ipairs(folds.ranges) do - if not prev_starts[range.from] then - vim.fn.cursor(range.from, 1) - vim.cmd('silent! normal! zc') + if range.from <= line_count and range.to <= line_count then + vim.cmd(range.from .. ',' .. range.to .. 'fold') end end for _, range in ipairs(folds.ranges) do if was_open[range.from] then - vim.fn.cursor(range.from, 1) - vim.cmd('silent! normal! zo') + vim.cmd(range.from .. ',' .. range.to .. 'foldopen!') end end + -- stay manual; switching to expr re-evaluates foldexpr per-line vim.fn.winrestview(view) end) end @@ -692,11 +689,22 @@ function M.setup_autocmds(windows, group) end, }) + -- Lazy-render: load more messages on scroll-to-top (debounced) + local debounced_load_more = require('opencode.util').debounce(function() + local renderer = require('opencode.ui.renderer') + if renderer.load_more_messages() then + pcall(vim.api.nvim_win_set_cursor, windows.output_win, { 3, 0 }) + end + end, 150) vim.api.nvim_create_autocmd('WinScrolled', { group = group, buffer = windows.output_buf, callback = function() M.sync_cursor_with_viewport(windows.output_win) + local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win) + if ok and cursor and cursor[1] <= 3 then + debounced_load_more() + end end, }) end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 895d254f..d983ed79 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -12,6 +12,25 @@ local M = {} local HIDDEN_MESSAGES_NOTICE_MESSAGE_ID = '__opencode_hidden_messages_notice__' local HIDDEN_MESSAGES_NOTICE_PART_ID = '__opencode_hidden_messages_notice_part__' +-- Lazy-render: render only viewport-sized message count on initial load, +-- load more on scroll-to-top. +local EST_LINES_PER_MSG = 5 +local VIEWPORT_BUFFER = 1.5 + +---Calculate how many messages to render initially based on window height. +---@return integer +local function get_initial_render_count() + local win = state.windows and state.windows.output_win + if not win or not vim.api.nvim_win_is_valid(win) then + return math.huge -- no window: render all (tests, headless) + end + local ok, height = pcall(vim.api.nvim_win_get_height, win) + if not ok or not height or height <= 0 then + return math.huge + end + return math.ceil(height / EST_LINES_PER_MSG * VIEWPORT_BUFFER) +end + ---@return integer|nil local function get_max_rendered_messages() local limit = config.ui and config.ui.output and config.ui.output.max_messages @@ -319,6 +338,9 @@ end ---@param opts? { restore_model_from_messages?: boolean } function M._render_full_session_data(session_data, opts) opts = opts or {} + -- Read before reset() clears it + local lazy_limit = ctx.lazy_render_count + local t_start = vim.uv.hrtime() M.reset() state.renderer.set_messages(session_data or {}) @@ -329,6 +351,22 @@ function M._render_full_session_data(session_data, opts) local visible_messages, hidden_count = get_visible_session_messages(state.messages) local revert_index = get_revert_index(state.messages) + -- Lazy-render: only render viewport-sized message count. + -- Active when ctx.lazy_render_count is set or opts.lazy=true. + if lazy_limit == nil and opts.lazy then + local initial = get_initial_render_count() + if #visible_messages > initial then + lazy_limit = initial + end + end + -- Persist back to ctx so it survives the next M.reset() + ctx.lazy_render_count = lazy_limit + if lazy_limit and #visible_messages > lazy_limit then + hidden_count = hidden_count + (#visible_messages - lazy_limit) + visible_messages = vim.list_slice(visible_messages, #visible_messages - lazy_limit + 1) + end + + local t_format_start = vim.uv.hrtime() flush.begin_bulk_mode() if hidden_count > 0 then @@ -376,8 +414,10 @@ function M._render_full_session_data(session_data, opts) events.on_part_updated({ part = revert_message.parts[1] }) end + local t_format_end = vim.uv.hrtime() flush.flush() flush.end_bulk_mode() + local t_flush_end = vim.uv.hrtime() if opts.restore_model_from_messages then require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true }) @@ -399,6 +439,7 @@ function M.render_from_cache(session_data) end M._render_full_session_data(session_data, { restore_model_from_messages = true, + lazy = ctx.lazy_render_count ~= nil, }) local active_session = state.active_session if active_session and active_session.id then @@ -407,6 +448,28 @@ function M.render_from_cache(session_data) end end +---Load more older messages into the output buffer. +---Called when user scrolls to the top of the output window. +---@return boolean Whether more messages were loaded +function M.load_more_messages() + if not state.messages then + return false + end + local total = #get_visible_session_messages(state.messages) + if total == 0 then + return false + end + local current = ctx.lazy_render_count or math.min(get_initial_render_count(), total) + if current >= total then + return false + end + + -- Load another viewport's worth + ctx.lazy_render_count = math.min(current + get_initial_render_count(), total) + M.render_from_cache(state.messages) + return true +end + ---Fetch the active session from the server and render it ---@return Promise function M.render_full_session() @@ -416,6 +479,7 @@ function M.render_full_session() return fetch_session():and_then(function(session_data) M._render_full_session_data(session_data, { restore_model_from_messages = true, + lazy = true, }) local active_session = state.active_session if active_session and active_session.id then diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 74a31598..869fe502 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -33,6 +33,8 @@ local ctx = { global_folds = {}, ---@type table part_folds = {}, + ---@type integer|nil Number of messages to render from the end (nil = all) + lazy_render_count = nil, } ---Reset all renderer caches and pending state. @@ -56,6 +58,7 @@ function ctx:reset() self.markdown_render_scheduled = false self.global_folds = {} self.part_folds = {} + self.lazy_render_count = nil self:bulk_reset() end diff --git a/tests/unit/renderer_lazy_spec.lua b/tests/unit/renderer_lazy_spec.lua new file mode 100644 index 00000000..0804edbb --- /dev/null +++ b/tests/unit/renderer_lazy_spec.lua @@ -0,0 +1,209 @@ +local helpers = require('tests.helpers') +local state = require('opencode.state') +local ctx = require('opencode.ui.renderer.ctx') +local stub = require('luassert.stub') + +---Create a minimal message for testing lazy render. +---@param id string Message ID +---@param role string 'user' or 'assistant' +---@return OpencodeMessage +local function make_message(id, role) + return { + info = { + id = id, + sessionID = 'ses_test', + role = role, + time = { created = 1000 }, + }, + parts = { + { + id = id .. '_part', + messageID = id, + sessionID = 'ses_test', + type = 'text', + text = 'Message ' .. id, + state = {}, + }, + }, + } +end + +---Create a list of N user/assistant message pairs. +---@param count integer Number of message pairs +---@return OpencodeMessage[] +local function make_session_data(count) + local messages = {} + for i = 1, count do + table.insert(messages, make_message('msg_u' .. i, 'user')) + table.insert(messages, make_message('msg_a' .. i, 'assistant')) + end + return messages +end + +---Count rendered real messages (excluding synthetic notices) in the render_state. +---@return integer +local function count_rendered_messages() + local count = 0 + for _, msg in ipairs(state.messages or {}) do + local msg_id = msg.info and msg.info.id or '' + -- Skip synthetic messages (hidden notice, revert display) + if msg_id:match('^__opencode_') then + goto continue + end + local rendered = ctx.render_state:get_message(msg_id) + if rendered and rendered.line_start and rendered.line_end then + count = count + 1 + end + ::continue:: + end + return count +end + +describe('lazy render', function() + local renderer + + before_each(function() + helpers.replay_setup() + renderer = require('opencode.ui.renderer') + + state.session.set_active({ id = 'ses_test', title = 'Test Session' }) + end) + + after_each(function() + ctx:reset() + if state.windows then + require('opencode.ui.ui').close_windows(state.windows) + end + end) + + it('renders all messages when lazy=false', function() + local session_data = make_session_data(10) + renderer._render_full_session_data(session_data, { lazy = false }) + + -- All 20 messages (10 user + 10 assistant) should be rendered + assert.are.equal(20, count_rendered_messages()) + end) + + it('renders limited messages when ctx.lazy_render_count is set', function() + local session_data = make_session_data(50) -- 100 messages total + + -- Set lazy_render_count before calling render — this simulates + -- load_more_messages having been called previously + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data, { lazy = true }) + + -- Only 10 of 100 messages should be rendered (from the end) + assert.are.equal(10, count_rendered_messages()) + -- lazy_render_count should be preserved (written back by render) + assert.are.equal(10, ctx.lazy_render_count) + end) + + it('preserves lazy_render_count increment across render reset', function() + -- This is the core test for the bug: load_more_messages sets + -- ctx.lazy_render_count, but _render_full_session_data calls M.reset() + -- which used to clear it. After the fix, lazy_render_count is read + -- before reset() and written back after. + + local session_data = make_session_data(50) -- 100 messages total + + -- Simulate initial load: render with lazy=true + -- Stub get_initial_render_count to return a small number so lazy kicks in + local initial_count = 10 + -- We can't easily stub a local function, so set ctx.lazy_render_count + -- directly to simulate what would happen after initial render + ctx.lazy_render_count = initial_count + renderer._render_full_session_data(session_data, { lazy = true }) + assert.are.equal(initial_count, count_rendered_messages()) + assert.are.equal(initial_count, ctx.lazy_render_count) + + -- Now simulate load_more_messages: increment lazy_render_count + local incremented = initial_count + 10 + ctx.lazy_render_count = incremented + + -- This render should preserve the incremented value across reset + renderer._render_full_session_data(session_data, { lazy = true }) + assert.are.equal(incremented, count_rendered_messages()) + assert.are.equal( + incremented, + ctx.lazy_render_count, + 'lazy_render_count should survive M.reset() — the original bug would clear it' + ) + end) + + it('load_more_messages increments rendered message count', function() + local session_data = make_session_data(50) -- 100 messages total + + -- Simulate initial lazy render with 10 messages + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data, { lazy = true }) + assert.are.equal(10, count_rendered_messages()) + + -- Call load_more_messages + local result = renderer.load_more_messages() + assert.is_true(result, 'load_more_messages should return true when more messages available') + -- lazy_render_count should have been incremented + assert.is_true( + ctx.lazy_render_count > 10, + 'lazy_render_count should be incremented, got ' .. tostring(ctx.lazy_render_count) + ) + end) + + it('load_more_messages returns false when all messages are loaded', function() + local session_data = make_session_data(5) -- 10 messages total + + -- Render with lazy_render_count covering all messages + ctx.lazy_render_count = 100 + renderer._render_full_session_data(session_data, { lazy = true }) + + -- All messages are loaded, load_more should return false + local result = renderer.load_more_messages() + assert.is_false(result, 'load_more_messages should return false when all loaded') + end) + + it('load_more_messages returns false when no messages', function() + -- Render with empty data + renderer._render_full_session_data({}, { lazy = true }) + + local result = renderer.load_more_messages() + assert.is_false(result, 'load_more_messages should return false when no messages') + end) +end) + +describe('renderer no debug logging', function() + before_each(function() + helpers.replay_setup() + state.session.set_active({ id = 'ses_test', title = 'Test Session' }) + end) + + after_each(function() + ctx:reset() + if state.windows then + require('opencode.ui.ui').close_windows(state.windows) + end + end) + + it('does not emit INFO-level notifications during rendering', function() + local mock = helpers.mock_notify() + local renderer = require('opencode.ui.renderer') + local session_data = make_session_data(5) + + renderer._render_full_session_data(session_data, { lazy = false }) + + -- Process any vim.schedule callbacks + vim.wait(100) + + local notifications = mock.get_notifications() + mock.reset() + + for _, n in ipairs(notifications) do + if n.level == vim.log.levels.INFO then + assert.is_not_match( + '%[render_full%]', + n.msg, + 'DEBUG: [render_full] notification should not be emitted: ' .. n.msg + ) + assert.is_not_match('%[e2e%]', n.msg, 'DEBUG: [e2e] notification should not be emitted: ' .. n.msg) + end + end + end) +end) From bbec46b0bbc9b8038a51bd695a8f984aa4c99f8d Mon Sep 17 00:00:00 2001 From: jensenojs Date: Mon, 1 Jun 2026 20:16:14 +0800 Subject: [PATCH 2/3] refactor(render): remove redundant foldexpr path --- lua/opencode/ui/output_window.lua | 65 ++----------------------------- lua/opencode/ui/renderer.lua | 8 ++-- tests/unit/output_window_spec.lua | 23 ++--------- 3 files changed, 10 insertions(+), 86 deletions(-) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 53cfbf62..22f9c89a 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -14,7 +14,6 @@ M._prev_line_count_by_win = {} local function build_fold_state(folds) local fold_state = { ranges = {}, - starts = {}, } for _, range in ipairs(folds or {}) do @@ -23,53 +22,29 @@ local function build_fold_state(folds) from = range.from, to = range.to, } - fold_state.starts[#fold_state.starts + 1] = range.from end end table.sort(fold_state.ranges, function(a, b) return a.from < b.from end) - table.sort(fold_state.starts) return fold_state end ---@param buf integer ----@return { ranges: table<{from: integer, to: integer}>, starts: integer[] } +---@return { ranges: table<{from: integer, to: integer}> } local function get_fold_state(buf) local ok, fold_state = pcall(vim.api.nvim_buf_get_var, buf, 'opencode_folds') if not ok or type(fold_state) ~= 'table' then - return { ranges = {}, starts = {} } + return { ranges = {} } end - if type(fold_state.ranges) == 'table' and type(fold_state.starts) == 'table' then + if type(fold_state.ranges) == 'table' then return fold_state end return build_fold_state(fold_state) end ----@param ranges table<{from: integer, to: integer}> ----@param line integer ----@return boolean -local function line_in_fold(ranges, line) - local lo = 1 - local hi = #ranges - - while lo <= hi do - local mid = math.floor((lo + hi) / 2) - local range = ranges[mid] - if line < range.from then - hi = mid - 1 - elseif line > range.to then - lo = mid + 1 - else - return true - end - end - - return false -end - local _update_depth = 0 local _update_buf = nil @@ -243,8 +218,7 @@ function M.setup(windows) window_options.set_buffer_option('swapfile', false, windows.output_buf) window_options.set_buffer_option('undofile', false, windows.output_buf) window_options.set_buffer_option('undolevels', -1, windows.output_buf) - window_options.set_window_option('foldmethod', 'expr', windows.output_win) - window_options.set_window_option('foldexpr', 'v:lua.opencode_fold_expr()', windows.output_win) + window_options.set_window_option('foldmethod', 'manual', windows.output_win) window_options.set_window_option('foldenable', true, windows.output_win) window_options.set_window_option('foldlevel', 0, windows.output_win) window_options.set_window_option('foldcolumn', '1', windows.output_win) @@ -307,32 +281,6 @@ function M.update_dimensions(windows) pcall(vim.api.nvim_win_set_config, windows.output_win, { width = width }) end ----Fold expression for the output buffer ----@return number -function M.fold_expr() - local output_buf = nil - - local windows = state.windows - if windows and windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf) then - output_buf = windows.output_buf - else - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_has_var(buf, 'opencode_folds') then - output_buf = buf - break - end - end - end - - if not output_buf then - return 0 - end - - local line = vim.v.lnum - local fold_state = get_fold_state(output_buf) - return line_in_fold(fold_state.ranges, line) and 1 or 0 -end - ---Fold text for the output buffer ---@return string function M.fold_text() @@ -363,7 +311,6 @@ function M.fold_text() return vim.fn.foldtext() end -_G.opencode_fold_expr = M.fold_expr _G.opencode_fold_text = M.fold_text function M.get_open_fold_starts(win, buf) @@ -409,9 +356,6 @@ function M.set_folds(fold_ranges) vim.api.nvim_win_call(win, function() local view = vim.fn.winsaveview() - -- manual avoids foldexpr recalculation on each fold operation - vim.api.nvim_set_option_value('foldmethod', 'manual', { win = 0 }) - local line_count = vim.api.nvim_buf_line_count(buf) for _, range in ipairs(folds.ranges) do if range.from <= line_count and range.to <= line_count then @@ -425,7 +369,6 @@ function M.set_folds(fold_ranges) end end - -- stay manual; switching to expr re-evaluates foldexpr per-line vim.fn.winrestview(view) end) end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index d983ed79..5be87b1b 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -12,10 +12,8 @@ local M = {} local HIDDEN_MESSAGES_NOTICE_MESSAGE_ID = '__opencode_hidden_messages_notice__' local HIDDEN_MESSAGES_NOTICE_PART_ID = '__opencode_hidden_messages_notice_part__' --- Lazy-render: render only viewport-sized message count on initial load, --- load more on scroll-to-top. -local EST_LINES_PER_MSG = 5 -local VIEWPORT_BUFFER = 1.5 +local LAZYRENDER_EST_LINES_PER_MSG = 5 +local LAZYRENDER_VIEWPORT_BUFFER = 1.5 ---Calculate how many messages to render initially based on window height. ---@return integer @@ -28,7 +26,7 @@ local function get_initial_render_count() if not ok or not height or height <= 0 then return math.huge end - return math.ceil(height / EST_LINES_PER_MSG * VIEWPORT_BUFFER) + return math.ceil(height / LAZYRENDER_EST_LINES_PER_MSG * LAZYRENDER_VIEWPORT_BUFFER) end ---@return integer|nil diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index 8d0f7c9a..6c5b2a1a 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -144,11 +144,13 @@ describe('output_window.setup', function() assert.is_false(cursorline) end) - it('defaults folds to closed for expr-based output folds', function() + it('uses manual folds for output fold ranges', function() output_window.setup({ output_buf = buf, output_win = win }) + local foldmethod = vim.api.nvim_get_option_value('foldmethod', { win = win }) local foldlevel = vim.api.nvim_get_option_value('foldlevel', { win = win }) + assert.equals('manual', foldmethod) assert.equals(0, foldlevel) end) @@ -176,7 +178,6 @@ describe('output_window.setup', function() { from = 1, to = 2 }, { from = 3, to = 5 }, }, - starts = { 1, 3 }, }, folds) end) @@ -189,26 +190,8 @@ describe('output_window.setup', function() local folds = vim.api.nvim_buf_get_var(buf, 'opencode_folds') assert.same({ ranges = { { from = 1, to = 3 } }, - starts = { 1 }, }, folds) end) - - it('evaluates fold_expr against the fold lookup structure', function() - output_window.setup({ output_buf = buf, output_win = win }) - output_window.set_folds({ { from = 2, to = 4 } }) - - local inside = vim.api.nvim_win_call(win, function() - vim.v.lnum = 3 - return output_window.fold_expr() - end) - local outside = vim.api.nvim_win_call(win, function() - vim.v.lnum = 5 - return output_window.fold_expr() - end) - - assert.equals(1, inside) - assert.equals(0, outside) - end) end) describe('output_window extmarks', function() From 1e41de49766cc952ba2c05afcddd372b4c58479e Mon Sep 17 00:00:00 2001 From: jensenojs Date: Mon, 1 Jun 2026 21:19:37 +0800 Subject: [PATCH 3/3] fix(output): set fold fillchars for cleaner fold column rendering --- lua/opencode/ui/output_window.lua | 1 + tests/unit/output_window_spec.lua | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 22f9c89a..9784f928 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -222,6 +222,7 @@ function M.setup(windows) window_options.set_window_option('foldenable', true, windows.output_win) window_options.set_window_option('foldlevel', 0, windows.output_win) window_options.set_window_option('foldcolumn', '1', windows.output_win) + window_options.set_window_option('fillchars', 'fold:-,foldopen:-,foldclose:+,foldsep:│', windows.output_win) window_options.set_window_option('foldtext', 'v:lua.opencode_fold_text()', windows.output_win) if config.ui.position ~= 'current' then diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index 6c5b2a1a..12e14f6a 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -154,6 +154,14 @@ describe('output_window.setup', function() assert.equals(0, foldlevel) end) + it('sets fold fillchars to avoid numeric fold column markers', function() + output_window.setup({ output_buf = buf, output_win = win }) + + local fillchars = vim.api.nvim_get_option_value('fillchars', { win = win }) + + assert.equals('fold:-,foldopen:-,foldclose:+,foldsep:│', fillchars) + end) + it('applies closed folds immediately when fold ranges change', function() output_window.setup({ output_buf = buf, output_win = win }) output_window.set_lines({ 'a', 'b', 'c', 'd' })