diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 5bca0207..455e90e1 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,11 +218,11 @@ 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) + 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 @@ -307,32 +282,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 +312,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) @@ -404,28 +352,21 @@ 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 + 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 @@ -652,6 +593,14 @@ end function M.setup_keymaps(windows) local keymap = require('opencode.keymap') keymap.setup_window_keymaps(config.keymap.output_window, windows.output_buf) + + -- When lazy-render is active, gg only reaches the top of rendered content. + -- Load all messages first so gg reaches the true start of history. + vim.keymap.set('n', 'gg', function() + local renderer = require('opencode.ui.renderer') + renderer.load_all_messages() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + end, { buffer = windows.output_buf }) end ---@param windows OpencodeWindowState @@ -692,13 +641,58 @@ function M.setup_autocmds(windows, group) end, }) - vim.api.nvim_create_autocmd('WinScrolled', { - group = group, - buffer = windows.output_buf, - callback = function() - M.sync_cursor_with_viewport(windows.output_win) - 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') + local render_state = require('opencode.ui.renderer.ctx').render_state + -- Save the message at the top of the viewport before loading more + local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win) + local anchor_msg_id = nil + local anchor_was_at_line = nil + if ok and cursor then + local top_line = cursor[1] + for _, msg in ipairs(state.messages or {}) do + local msg_id = msg.info and msg.info.id or '' + if not msg_id:match('^__opencode_') then + local rendered = render_state:get_message(msg_id) + if rendered and rendered.line_start and rendered.line_start >= top_line then + anchor_msg_id = msg_id + anchor_was_at_line = rendered.line_start + break + end + end + end + end + + if renderer.load_more_messages() then + -- Restore cursor to the anchor message's new position + if anchor_msg_id then + local rendered = render_state:get_message(anchor_msg_id) + if rendered and rendered.line_start then + pcall(vim.api.nvim_win_set_cursor, windows.output_win, { rendered.line_start, 0 }) + return + end + end + -- Fallback: move to top of buffer + pcall(vim.api.nvim_win_set_cursor, windows.output_win, { 1, 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 ctx = require('opencode.ui.renderer.ctx') + local has_unrendered = ctx.lazy_render_count ~= nil + and ctx.lazy_render_count < #state.messages + if has_unrendered then + 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, + }) end ---Clear the output buffer and all namespaces. diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 895d254f..5677a6eb 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -12,6 +12,23 @@ local M = {} local HIDDEN_MESSAGES_NOTICE_MESSAGE_ID = '__opencode_hidden_messages_notice__' local HIDDEN_MESSAGES_NOTICE_PART_ID = '__opencode_hidden_messages_notice_part__' +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 +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 / LAZYRENDER_EST_LINES_PER_MSG * LAZYRENDER_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 +336,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 +349,18 @@ 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) + if lazy_limit == nil then + local initial = get_initial_render_count() + if #visible_messages > initial then + lazy_limit = initial + end + end + ctx.lazy_render_count = lazy_limit + if lazy_limit and #visible_messages > lazy_limit then + 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 +408,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 }) @@ -397,9 +431,9 @@ function M.render_from_cache(session_data) if not output_window.mounted() or not state.api_client then return end - M._render_full_session_data(session_data, { - restore_model_from_messages = true, - }) + M._render_full_session_data(session_data, { + restore_model_from_messages = true, + }) local active_session = state.active_session if active_session and active_session.id then require('opencode.ui.question_window').restore_pending_question(active_session.id) @@ -407,6 +441,53 @@ 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 + -- nil means no lazy limit → all messages already rendered + if not ctx.lazy_render_count then + return false + end + local total = #get_visible_session_messages(state.messages) + if total == 0 then + return false + end + if ctx.lazy_render_count >= total then + return false + end + + -- Load another viewport's worth + ctx.lazy_render_count = math.min(ctx.lazy_render_count + get_initial_render_count(), total) + M.render_from_cache(state.messages) + return true +end + +---Load all remaining messages and re-render. +---Used when user explicitly navigates to the top (gg) to ensure +---the full history is available for navigation and search. +---@return boolean Whether any messages were loaded +function M.load_all_messages() + if not state.messages then + return false + end + local total = #get_visible_session_messages(state.messages) + if total == 0 then + return false + end + -- nil means no lazy limit → all messages already rendered + if not ctx.lazy_render_count or ctx.lazy_render_count >= total then + return false + end + + ctx.lazy_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() @@ -414,9 +495,9 @@ function M.render_full_session() return Promise.new():resolve(nil) end return fetch_session():and_then(function(session_data) - M._render_full_session_data(session_data, { - restore_model_from_messages = true, - }) + M._render_full_session_data(session_data, { + restore_model_from_messages = true, + }) local active_session = state.active_session if active_session and active_session.id then require('opencode.ui.question_window').restore_pending_question(active_session.id) diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 74a31598..f8e552cc 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. diff --git a/tests/helpers.lua b/tests/helpers.lua index 2bb729ce..eb31302d 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -25,6 +25,8 @@ function M.replay_setup() end renderer.reset() + -- Ensure replay tests render all messages (lazy-render is always active) + require('opencode.ui.renderer.ctx').lazy_render_count = math.huge permission_window.clear_all() question_window._clear_dialog() question_window._current_question = nil diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index 8d0f7c9a..12e14f6a 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -144,14 +144,24 @@ 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) + 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' }) @@ -176,7 +186,6 @@ describe('output_window.setup', function() { from = 1, to = 2 }, { from = 3, to = 5 }, }, - starts = { 1, 3 }, }, folds) end) @@ -189,26 +198,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() diff --git a/tests/unit/renderer_lazy_spec.lua b/tests/unit/renderer_lazy_spec.lua new file mode 100644 index 00000000..0d9859c3 --- /dev/null +++ b/tests/unit/renderer_lazy_spec.lua @@ -0,0 +1,338 @@ +local helpers = require('tests.helpers') +local state = require('opencode.state') +local ctx = require('opencode.ui.renderer.ctx') +local config = require('opencode.config') + +---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 '' + 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() + config.ui.output.max_messages = nil + if state.windows then + require('opencode.ui.ui').close_windows(state.windows) + end + end) + + it('truncates to lazy_render_count from the end', function() + local session_data = make_session_data(50) -- 100 messages total + + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data) + + assert.are.equal(10, count_rendered_messages()) + assert.are.equal(10, ctx.lazy_render_count) + + -- Verify it's the LAST 10 messages rendered (not the first) + local last_msg = session_data[#session_data] + local rendered = ctx.render_state:get_message(last_msg.info.id) + assert.is_truthy(rendered and rendered.line_start, 'last message should be rendered') + + local first_msg = session_data[1] + local not_rendered = ctx.render_state:get_message(first_msg.info.id) + assert.is_falsy(not_rendered and not_rendered.line_start, 'first message should not be rendered') + end) + + it('preserves lazy_render_count across render reset', function() + local session_data = make_session_data(50) -- 100 messages total + + local initial_count = 10 + ctx.lazy_render_count = initial_count + renderer._render_full_session_data(session_data) + assert.are.equal(initial_count, count_rendered_messages()) + assert.are.equal(initial_count, ctx.lazy_render_count) + + -- 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) + 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 and re-renders', function() + local session_data = make_session_data(50) -- 100 messages total + + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data) + assert.are.equal(10, count_rendered_messages()) + + -- Simulate what load_more_messages does: increment count and re-render + local current = ctx.lazy_render_count + ctx.lazy_render_count = current + 10 + renderer._render_full_session_data(session_data) + + assert.are.equal(20, count_rendered_messages()) + assert.are.equal(20, ctx.lazy_render_count) + + -- When count exceeds total, all messages are rendered + ctx.lazy_render_count = 200 + renderer._render_full_session_data(session_data) + assert.are.equal(100, count_rendered_messages()) + + -- load_more_messages returns false when all loaded + assert.is_false(renderer.load_more_messages()) + end) + + it('load_more_messages places older messages above previously rendered ones', function() + local session_data = make_session_data(50) -- 100 messages total + + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data) + + -- Record the line position of the last message (most recent) + local last_msg = session_data[#session_data] + local rendered_before = ctx.render_state:get_message(last_msg.info.id) + local line_end_before = rendered_before and rendered_before.line_end + + -- Simulate load_more: increment and re-render + ctx.lazy_render_count = ctx.lazy_render_count + 10 + renderer._render_full_session_data(session_data) + + -- After loading more, the last message should have shifted down + -- (older messages were inserted above it) + local rendered_after = ctx.render_state:get_message(last_msg.info.id) + local line_end_after = rendered_after and rendered_after.line_end + + assert.is_truthy(line_end_before, 'last message should be rendered before load') + assert.is_truthy(line_end_after, 'last message should be rendered after load') + assert.is_true( + line_end_after > line_end_before, + string.format( + 'loading more messages should shift existing messages down (was line %d, now line %d)', + line_end_before, + line_end_after + ) + ) + end) + + it('load_more_messages returns false for empty session', function() + renderer._render_full_session_data({}) + assert.is_false(renderer.load_more_messages()) + end) + + it('lazy-render does not exceed max_messages ceiling', function() + config.ui.output.max_messages = 20 + local session_data = make_session_data(50) -- 100 messages total + + ctx.lazy_render_count = 30 + renderer._render_full_session_data(session_data) + + -- max_messages=20 caps at 20 visible, lazy_render_count=30 can't exceed that + local max_msgs_visible = 20 + assert.are.equal(max_msgs_visible, count_rendered_messages()) + end) + + it('unrendered messages are not in the buffer', function() + local session_data = make_session_data(50) -- 100 messages total + + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data) + assert.are.equal(10, count_rendered_messages()) + + local output_buf = state.windows and state.windows.output_buf + if output_buf and vim.api.nvim_buf_is_valid(output_buf) then + local buf_text = table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), '\n') + + -- Rendered (recent) message IS in the buffer + assert.is_match('Message msg_u50', buf_text) + + -- Unrendered (old) message is NOT in the buffer + assert.is_not_match('Message msg_u1', buf_text) + end + end) + + it('load_more_messages returns false when all messages already rendered', function() + -- No lazy_render_count means all messages rendered — no unrendered content + local session_data = make_session_data(50) -- 100 messages total + renderer._render_full_session_data(session_data) + + -- lazy_render_count was set by _render_full_session_data; verify the guard + -- After full render with a lazy limit that covers everything, load_more returns false + ctx.lazy_render_count = 100 + assert.is_false(renderer.load_more_messages(), + 'should return false when lazy_render_count covers all messages') + + -- nil means no lazy limit at all → nothing to load + ctx.lazy_render_count = nil + assert.is_false(renderer.load_more_messages(), + 'should return false when lazy_render_count is nil') + end) + + it('load_more_messages returns true only when unrendered messages exist', function() + local session_data = make_session_data(50) -- 100 messages total + + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data) + + -- Stub render_from_cache to avoid test-env dependency + local stub = require('luassert.stub') + local _rfc = stub(renderer, 'render_from_cache') + + -- 10 rendered out of 100 → has unrendered → load_more should work + assert.is_true(renderer.load_more_messages(), + 'should return true when unrendered messages exist') + + _rfc:revert() + end) + + it('has_unrendered gates scroll-to-top load_more', function() + -- When lazy_render_count covers all messages, scrolling to top + -- should NOT trigger load_more. This tests the guard condition + -- that prevents spurious loads when everything is already rendered. + local session_data = make_session_data(50) -- 100 messages total + + -- Case 1: all rendered (lazy_render_count covers everything) + ctx.lazy_render_count = 100 + renderer._render_full_session_data(session_data) + assert.is_false(renderer.load_more_messages(), + 'no load_more when lazy_render_count covers all messages') + + -- Case 2: partial render → load_more returns true + local stub = require('luassert.stub') + local _rfc = stub(renderer, 'render_from_cache') + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data) + assert.is_true(renderer.load_more_messages(), + 'load_more returns true when unrendered messages exist') + _rfc:revert() + + -- Case 3: nil (never set) → load_more returns false + ctx.lazy_render_count = nil + assert.is_false(renderer.load_more_messages(), + 'no load_more when lazy_render_count is nil') + end) + + it('load_all_messages renders everything and makes it searchable', function() + local session_data = make_session_data(50) -- 100 messages total + + ctx.lazy_render_count = 10 + renderer._render_full_session_data(session_data) + assert.are.equal(10, count_rendered_messages()) + + -- Before load_all: old message is not in the buffer + local output_buf = state.windows and state.windows.output_buf + if output_buf and vim.api.nvim_buf_is_valid(output_buf) then + local buf_text = table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), '\n') + assert.is_not_match('Message msg_u1', buf_text) + end + + -- Simulate load_all_messages (sets count to total and re-renders). + -- Can't call load_all_messages directly — render_from_cache requires api_client. + ctx.lazy_render_count = 100 + renderer._render_full_session_data(session_data) + assert.are.equal(100, count_rendered_messages()) + + -- After load_all: old message IS in the buffer and searchable + if output_buf and vim.api.nvim_buf_is_valid(output_buf) then + local buf_text = table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), '\n') + assert.is_match('Message msg_u1', buf_text, + 'after load_all_messages, all messages should be searchable in the output buffer') + end + 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) + + 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)