Skip to content

Commit a05543b

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 a05543b

4 files changed

Lines changed: 295 additions & 11 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 19 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,22 @@ function M.setup_autocmds(windows, group)
692689
end,
693690
})
694691

692+
-- Lazy-render: load more messages on scroll-to-top (debounced)
693+
local debounced_load_more = require('opencode.util').debounce(function()
694+
local renderer = require('opencode.ui.renderer')
695+
if renderer.load_more_messages() then
696+
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { 3, 0 })
697+
end
698+
end, 150)
695699
vim.api.nvim_create_autocmd('WinScrolled', {
696700
group = group,
697701
buffer = windows.output_buf,
698702
callback = function()
699703
M.sync_cursor_with_viewport(windows.output_win)
704+
local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
705+
if ok and cursor and cursor[1] <= 3 then
706+
debounced_load_more()
707+
end
700708
end,
701709
})
702710
end

lua/opencode/ui/renderer.lua

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ 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+
-- Lazy-render: render only viewport-sized message count on initial load,
16+
-- load more on scroll-to-top.
17+
local EST_LINES_PER_MSG = 5
18+
local VIEWPORT_BUFFER = 1.5
19+
20+
---Calculate how many messages to render initially based on window height.
21+
---@return integer
22+
local function get_initial_render_count()
23+
local win = state.windows and state.windows.output_win
24+
if not win or not vim.api.nvim_win_is_valid(win) then
25+
return math.huge -- no window: render all (tests, headless)
26+
end
27+
local ok, height = pcall(vim.api.nvim_win_get_height, win)
28+
if not ok or not height or height <= 0 then
29+
return math.huge
30+
end
31+
return math.ceil(height / EST_LINES_PER_MSG * VIEWPORT_BUFFER)
32+
end
33+
1534
---@return integer|nil
1635
local function get_max_rendered_messages()
1736
local limit = config.ui and config.ui.output and config.ui.output.max_messages
@@ -319,6 +338,9 @@ end
319338
---@param opts? { restore_model_from_messages?: boolean }
320339
function M._render_full_session_data(session_data, opts)
321340
opts = opts or {}
341+
-- Read before reset() clears it
342+
local lazy_limit = ctx.lazy_render_count
343+
local t_start = vim.uv.hrtime()
322344
M.reset()
323345
state.renderer.set_messages(session_data or {})
324346

@@ -329,6 +351,22 @@ function M._render_full_session_data(session_data, opts)
329351
local visible_messages, hidden_count = get_visible_session_messages(state.messages)
330352
local revert_index = get_revert_index(state.messages)
331353

354+
-- Lazy-render: only render viewport-sized message count.
355+
-- Active when ctx.lazy_render_count is set or opts.lazy=true.
356+
if lazy_limit == nil and opts.lazy then
357+
local initial = get_initial_render_count()
358+
if #visible_messages > initial then
359+
lazy_limit = initial
360+
end
361+
end
362+
-- Persist back to ctx so it survives the next M.reset()
363+
ctx.lazy_render_count = lazy_limit
364+
if lazy_limit and #visible_messages > lazy_limit then
365+
hidden_count = hidden_count + (#visible_messages - lazy_limit)
366+
visible_messages = vim.list_slice(visible_messages, #visible_messages - lazy_limit + 1)
367+
end
368+
369+
local t_format_start = vim.uv.hrtime()
332370
flush.begin_bulk_mode()
333371

334372
if hidden_count > 0 then
@@ -376,8 +414,10 @@ function M._render_full_session_data(session_data, opts)
376414
events.on_part_updated({ part = revert_message.parts[1] })
377415
end
378416

417+
local t_format_end = vim.uv.hrtime()
379418
flush.flush()
380419
flush.end_bulk_mode()
420+
local t_flush_end = vim.uv.hrtime()
381421

382422
if opts.restore_model_from_messages then
383423
require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true })
@@ -399,6 +439,7 @@ function M.render_from_cache(session_data)
399439
end
400440
M._render_full_session_data(session_data, {
401441
restore_model_from_messages = true,
442+
lazy = ctx.lazy_render_count ~= nil,
402443
})
403444
local active_session = state.active_session
404445
if active_session and active_session.id then
@@ -407,6 +448,28 @@ function M.render_from_cache(session_data)
407448
end
408449
end
409450

451+
---Load more older messages into the output buffer.
452+
---Called when user scrolls to the top of the output window.
453+
---@return boolean Whether more messages were loaded
454+
function M.load_more_messages()
455+
if not state.messages then
456+
return false
457+
end
458+
local total = #get_visible_session_messages(state.messages)
459+
if total == 0 then
460+
return false
461+
end
462+
local current = ctx.lazy_render_count or math.min(get_initial_render_count(), total)
463+
if current >= total then
464+
return false
465+
end
466+
467+
-- Load another viewport's worth
468+
ctx.lazy_render_count = math.min(current + get_initial_render_count(), total)
469+
M.render_from_cache(state.messages)
470+
return true
471+
end
472+
410473
---Fetch the active session from the server and render it
411474
---@return Promise<OpencodeMessage[]>
412475
function M.render_full_session()
@@ -416,6 +479,7 @@ function M.render_full_session()
416479
return fetch_session():and_then(function(session_data)
417480
M._render_full_session_data(session_data, {
418481
restore_model_from_messages = true,
482+
lazy = true,
419483
})
420484
local active_session = state.active_session
421485
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)