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)