Skip to content

Commit c7a6c60

Browse files
committed
perf(render): fix lazy-render and fold performance for large sessions
- 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
1 parent 3798e7f commit c7a6c60

4 files changed

Lines changed: 304 additions & 11 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -404,31 +404,28 @@ function M.set_folds(fold_ranges)
404404
end
405405

406406
local was_open = M.get_open_fold_starts(win, buf)
407-
408407
vim.api.nvim_buf_set_var(buf, 'opencode_folds', folds)
409408

410409
vim.api.nvim_win_call(win, function()
411410
local view = vim.fn.winsaveview()
412-
vim.cmd('silent! normal! zx')
413-
local prev_starts = {}
414-
for _, start_line in ipairs(prev_folds.starts) do
415-
prev_starts[start_line] = true
416-
end
417411

412+
-- manual avoids foldexpr recalculation on each fold operation
413+
vim.api.nvim_set_option_value('foldmethod', 'manual', { win = 0 })
414+
415+
local line_count = vim.api.nvim_buf_line_count(buf)
418416
for _, range in ipairs(folds.ranges) do
419-
if not prev_starts[range.from] then
420-
vim.fn.cursor(range.from, 1)
421-
vim.cmd('silent! normal! zc')
417+
if range.from <= line_count and range.to <= line_count then
418+
vim.cmd(range.from .. ',' .. range.to .. 'fold')
422419
end
423420
end
424421

425422
for _, range in ipairs(folds.ranges) do
426423
if was_open[range.from] then
427-
vim.fn.cursor(range.from, 1)
428-
vim.cmd('silent! normal! zo')
424+
vim.cmd(range.from .. ',' .. range.to .. 'foldopen!')
429425
end
430426
end
431427

428+
-- stay manual; switching to expr re-evaluates foldexpr per-line
432429
vim.fn.winrestview(view)
433430
end)
434431
end
@@ -692,11 +689,23 @@ function M.setup_autocmds(windows, group)
692689
end,
693690
})
694691

692+
-- Lazy-render: when user scrolls to the top, load more messages
693+
-- Debounced to prevent rapid re-renders during fast scrolling
694+
local debounced_load_more = require('opencode.util').debounce(function()
695+
local renderer = require('opencode.ui.renderer')
696+
if renderer.load_more_messages() then
697+
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { 3, 0 })
698+
end
699+
end, 150)
695700
vim.api.nvim_create_autocmd('WinScrolled', {
696701
group = group,
697702
buffer = windows.output_buf,
698703
callback = function()
699704
M.sync_cursor_with_viewport(windows.output_win)
705+
local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
706+
if ok and cursor and cursor[1] <= 3 then
707+
debounced_load_more()
708+
end
700709
end,
701710
})
702711
end

lua/opencode/ui/renderer.lua

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@ local M = {}
1212
local HIDDEN_MESSAGES_NOTICE_MESSAGE_ID = '__opencode_hidden_messages_notice__'
1313
local HIDDEN_MESSAGES_NOTICE_PART_ID = '__opencode_hidden_messages_notice_part__'
1414

15+
-- Internal lazy-render: only render enough messages to fill the viewport
16+
-- on initial load, load more on scroll-to-top.
17+
-- Estimated lines per message (header + body). Used to calculate how many
18+
-- messages to render initially without formatting the entire session.
19+
local EST_LINES_PER_MSG = 5
20+
-- Extra buffer ratio on top of the viewport estimate
21+
local VIEWPORT_BUFFER = 1.5
22+
23+
---Calculate how many messages to render initially based on window height.
24+
---@return integer
25+
local function get_initial_render_count()
26+
local win = state.windows and state.windows.output_win
27+
if not win or not vim.api.nvim_win_is_valid(win) then
28+
return math.huge -- no window: render all (tests, headless)
29+
end
30+
local ok, height = pcall(vim.api.nvim_win_get_height, win)
31+
if not ok or not height or height <= 0 then
32+
return math.huge
33+
end
34+
-- ceiling division: how many messages to fill the viewport + buffer
35+
return math.ceil(height / EST_LINES_PER_MSG * VIEWPORT_BUFFER)
36+
end
37+
1538
---@return integer|nil
1639
local function get_max_rendered_messages()
1740
local limit = config.ui and config.ui.output and config.ui.output.max_messages
@@ -319,6 +342,10 @@ end
319342
---@param opts? { restore_model_from_messages?: boolean }
320343
function M._render_full_session_data(session_data, opts)
321344
opts = opts or {}
345+
-- Read lazy_render_count BEFORE reset() clears it, so load_more_messages
346+
-- can incrementally increase the rendered count across re-renders.
347+
local lazy_limit = ctx.lazy_render_count
348+
local t_start = vim.uv.hrtime()
322349
M.reset()
323350
state.renderer.set_messages(session_data or {})
324351

@@ -329,6 +356,25 @@ function M._render_full_session_data(session_data, opts)
329356
local visible_messages, hidden_count = get_visible_session_messages(state.messages)
330357
local revert_index = get_revert_index(state.messages)
331358

359+
-- Apply lazy-render limit: only render enough messages to fill the viewport.
360+
-- User can scroll to top to load more. Only active when explicitly requested
361+
-- (lazy_render_count is set by load_more_messages) or when rendering a full
362+
-- session for the first time (opts.lazy = true).
363+
if lazy_limit == nil and opts.lazy then
364+
local initial = get_initial_render_count()
365+
if #visible_messages > initial then
366+
lazy_limit = initial
367+
end
368+
end
369+
-- Persist lazy_limit back to ctx so subsequent load_more_messages can
370+
-- read it before the next M.reset() call.
371+
ctx.lazy_render_count = lazy_limit
372+
if lazy_limit and #visible_messages > lazy_limit then
373+
hidden_count = hidden_count + (#visible_messages - lazy_limit)
374+
visible_messages = vim.list_slice(visible_messages, #visible_messages - lazy_limit + 1)
375+
end
376+
377+
local t_format_start = vim.uv.hrtime()
332378
flush.begin_bulk_mode()
333379

334380
if hidden_count > 0 then
@@ -376,8 +422,10 @@ function M._render_full_session_data(session_data, opts)
376422
events.on_part_updated({ part = revert_message.parts[1] })
377423
end
378424

425+
local t_format_end = vim.uv.hrtime()
379426
flush.flush()
380427
flush.end_bulk_mode()
428+
local t_flush_end = vim.uv.hrtime()
381429

382430
if opts.restore_model_from_messages then
383431
require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true })
@@ -399,6 +447,7 @@ function M.render_from_cache(session_data)
399447
end
400448
M._render_full_session_data(session_data, {
401449
restore_model_from_messages = true,
450+
lazy = ctx.lazy_render_count ~= nil,
402451
})
403452
local active_session = state.active_session
404453
if active_session and active_session.id then
@@ -407,6 +456,28 @@ function M.render_from_cache(session_data)
407456
end
408457
end
409458

459+
---Load more older messages into the output buffer.
460+
---Called when user scrolls to the top of the output window.
461+
---@return boolean Whether more messages were loaded
462+
function M.load_more_messages()
463+
if not state.messages then
464+
return false
465+
end
466+
local total = #get_visible_session_messages(state.messages)
467+
if total == 0 then
468+
return false
469+
end
470+
local current = ctx.lazy_render_count or math.min(get_initial_render_count(), total)
471+
if current >= total then
472+
return false
473+
end
474+
475+
-- Load another viewport's worth of messages
476+
ctx.lazy_render_count = math.min(current + get_initial_render_count(), total)
477+
M.render_from_cache(state.messages)
478+
return true
479+
end
480+
410481
---Fetch the active session from the server and render it
411482
---@return Promise<OpencodeMessage[]>
412483
function M.render_full_session()
@@ -416,6 +487,7 @@ function M.render_full_session()
416487
return fetch_session():and_then(function(session_data)
417488
M._render_full_session_data(session_data, {
418489
restore_model_from_messages = true,
490+
lazy = true,
419491
})
420492
local active_session = state.active_session
421493
if active_session and active_session.id then

lua/opencode/ui/renderer/ctx.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ local ctx = {
3333
global_folds = {},
3434
---@type table<string, {from: number, to: number}[]>
3535
part_folds = {},
36+
---@type integer|nil Number of messages to render from the end (nil = all)
37+
lazy_render_count = nil,
3638
}
3739

3840
---Reset all renderer caches and pending state.
@@ -56,6 +58,7 @@ function ctx:reset()
5658
self.markdown_render_scheduled = false
5759
self.global_folds = {}
5860
self.part_folds = {}
61+
self.lazy_render_count = nil
5962
self:bulk_reset()
6063
end
6164

0 commit comments

Comments
 (0)