Skip to content

Commit c46defe

Browse files
authored
perf(render): fix lazy-render and fold performance for large sessions (#393)
* 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 * refactor(render): remove redundant foldexpr path * fix(output): set fold fillchars for cleaner fold column rendering * refactor(render): make lazy-render always active, fix cursor and gg - Remove opts.lazy: lazy-render is always active as a perf optimization - Decouple hidden_count from lazy-render truncation (only max_messages counts) - Fix cursor jump after load_more: preserve position via anchor message - Add gg keymap: load all messages before jumping to top, so full history is searchable * fix(render): fix gg E565 and gate load_more on has_unrendered - Remove expr=true from gg keymap (causes E565 during buffer write) - Gate scroll-to-top load_more on has_unrendered, not bare line number - Fix load_more_messages treating nil as 'needs loading'
1 parent adc77d1 commit c46defe

6 files changed

Lines changed: 509 additions & 101 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 69 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ M._prev_line_count_by_win = {}
1414
local function build_fold_state(folds)
1515
local fold_state = {
1616
ranges = {},
17-
starts = {},
1817
}
1918

2019
for _, range in ipairs(folds or {}) do
@@ -23,53 +22,29 @@ local function build_fold_state(folds)
2322
from = range.from,
2423
to = range.to,
2524
}
26-
fold_state.starts[#fold_state.starts + 1] = range.from
2725
end
2826
end
2927

3028
table.sort(fold_state.ranges, function(a, b)
3129
return a.from < b.from
3230
end)
33-
table.sort(fold_state.starts)
3431

3532
return fold_state
3633
end
3734

3835
---@param buf integer
39-
---@return { ranges: table<{from: integer, to: integer}>, starts: integer[] }
36+
---@return { ranges: table<{from: integer, to: integer}> }
4037
local function get_fold_state(buf)
4138
local ok, fold_state = pcall(vim.api.nvim_buf_get_var, buf, 'opencode_folds')
4239
if not ok or type(fold_state) ~= 'table' then
43-
return { ranges = {}, starts = {} }
40+
return { ranges = {} }
4441
end
45-
if type(fold_state.ranges) == 'table' and type(fold_state.starts) == 'table' then
42+
if type(fold_state.ranges) == 'table' then
4643
return fold_state
4744
end
4845
return build_fold_state(fold_state)
4946
end
5047

51-
---@param ranges table<{from: integer, to: integer}>
52-
---@param line integer
53-
---@return boolean
54-
local function line_in_fold(ranges, line)
55-
local lo = 1
56-
local hi = #ranges
57-
58-
while lo <= hi do
59-
local mid = math.floor((lo + hi) / 2)
60-
local range = ranges[mid]
61-
if line < range.from then
62-
hi = mid - 1
63-
elseif line > range.to then
64-
lo = mid + 1
65-
else
66-
return true
67-
end
68-
end
69-
70-
return false
71-
end
72-
7348
local _update_depth = 0
7449
local _update_buf = nil
7550

@@ -243,11 +218,11 @@ function M.setup(windows)
243218
window_options.set_buffer_option('swapfile', false, windows.output_buf)
244219
window_options.set_buffer_option('undofile', false, windows.output_buf)
245220
window_options.set_buffer_option('undolevels', -1, windows.output_buf)
246-
window_options.set_window_option('foldmethod', 'expr', windows.output_win)
247-
window_options.set_window_option('foldexpr', 'v:lua.opencode_fold_expr()', windows.output_win)
221+
window_options.set_window_option('foldmethod', 'manual', windows.output_win)
248222
window_options.set_window_option('foldenable', true, windows.output_win)
249223
window_options.set_window_option('foldlevel', 0, windows.output_win)
250224
window_options.set_window_option('foldcolumn', '1', windows.output_win)
225+
window_options.set_window_option('fillchars', 'fold:-,foldopen:-,foldclose:+,foldsep:│', windows.output_win)
251226
window_options.set_window_option('foldtext', 'v:lua.opencode_fold_text()', windows.output_win)
252227

253228
if config.ui.position ~= 'current' then
@@ -307,32 +282,6 @@ function M.update_dimensions(windows)
307282
pcall(vim.api.nvim_win_set_config, windows.output_win, { width = width })
308283
end
309284

310-
---Fold expression for the output buffer
311-
---@return number
312-
function M.fold_expr()
313-
local output_buf = nil
314-
315-
local windows = state.windows
316-
if windows and windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf) then
317-
output_buf = windows.output_buf
318-
else
319-
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
320-
if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_has_var(buf, 'opencode_folds') then
321-
output_buf = buf
322-
break
323-
end
324-
end
325-
end
326-
327-
if not output_buf then
328-
return 0
329-
end
330-
331-
local line = vim.v.lnum
332-
local fold_state = get_fold_state(output_buf)
333-
return line_in_fold(fold_state.ranges, line) and 1 or 0
334-
end
335-
336285
---Fold text for the output buffer
337286
---@return string
338287
function M.fold_text()
@@ -363,7 +312,6 @@ function M.fold_text()
363312
return vim.fn.foldtext()
364313
end
365314

366-
_G.opencode_fold_expr = M.fold_expr
367315
_G.opencode_fold_text = M.fold_text
368316

369317
function M.get_open_fold_starts(win, buf)
@@ -404,28 +352,21 @@ function M.set_folds(fold_ranges)
404352
end
405353

406354
local was_open = M.get_open_fold_starts(win, buf)
407-
408355
vim.api.nvim_buf_set_var(buf, 'opencode_folds', folds)
409356

410357
vim.api.nvim_win_call(win, function()
411358
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
417359

360+
local line_count = vim.api.nvim_buf_line_count(buf)
418361
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')
362+
if range.from <= line_count and range.to <= line_count then
363+
vim.cmd(range.from .. ',' .. range.to .. 'fold')
422364
end
423365
end
424366

425367
for _, range in ipairs(folds.ranges) do
426368
if was_open[range.from] then
427-
vim.fn.cursor(range.from, 1)
428-
vim.cmd('silent! normal! zo')
369+
vim.cmd(range.from .. ',' .. range.to .. 'foldopen!')
429370
end
430371
end
431372

@@ -652,6 +593,14 @@ end
652593
function M.setup_keymaps(windows)
653594
local keymap = require('opencode.keymap')
654595
keymap.setup_window_keymaps(config.keymap.output_window, windows.output_buf)
596+
597+
-- When lazy-render is active, gg only reaches the top of rendered content.
598+
-- Load all messages first so gg reaches the true start of history.
599+
vim.keymap.set('n', 'gg', function()
600+
local renderer = require('opencode.ui.renderer')
601+
renderer.load_all_messages()
602+
vim.api.nvim_win_set_cursor(0, { 1, 0 })
603+
end, { buffer = windows.output_buf })
655604
end
656605

657606
---@param windows OpencodeWindowState
@@ -692,13 +641,58 @@ function M.setup_autocmds(windows, group)
692641
end,
693642
})
694643

695-
vim.api.nvim_create_autocmd('WinScrolled', {
696-
group = group,
697-
buffer = windows.output_buf,
698-
callback = function()
699-
M.sync_cursor_with_viewport(windows.output_win)
700-
end,
701-
})
644+
-- Lazy-render: load more messages on scroll-to-top (debounced)
645+
local debounced_load_more = require('opencode.util').debounce(function()
646+
local renderer = require('opencode.ui.renderer')
647+
local render_state = require('opencode.ui.renderer.ctx').render_state
648+
-- Save the message at the top of the viewport before loading more
649+
local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
650+
local anchor_msg_id = nil
651+
local anchor_was_at_line = nil
652+
if ok and cursor then
653+
local top_line = cursor[1]
654+
for _, msg in ipairs(state.messages or {}) do
655+
local msg_id = msg.info and msg.info.id or ''
656+
if not msg_id:match('^__opencode_') then
657+
local rendered = render_state:get_message(msg_id)
658+
if rendered and rendered.line_start and rendered.line_start >= top_line then
659+
anchor_msg_id = msg_id
660+
anchor_was_at_line = rendered.line_start
661+
break
662+
end
663+
end
664+
end
665+
end
666+
667+
if renderer.load_more_messages() then
668+
-- Restore cursor to the anchor message's new position
669+
if anchor_msg_id then
670+
local rendered = render_state:get_message(anchor_msg_id)
671+
if rendered and rendered.line_start then
672+
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { rendered.line_start, 0 })
673+
return
674+
end
675+
end
676+
-- Fallback: move to top of buffer
677+
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { 1, 0 })
678+
end
679+
end, 150)
680+
vim.api.nvim_create_autocmd('WinScrolled', {
681+
group = group,
682+
buffer = windows.output_buf,
683+
callback = function()
684+
M.sync_cursor_with_viewport(windows.output_win)
685+
local ctx = require('opencode.ui.renderer.ctx')
686+
local has_unrendered = ctx.lazy_render_count ~= nil
687+
and ctx.lazy_render_count < #state.messages
688+
if has_unrendered then
689+
local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
690+
if ok and cursor and cursor[1] <= 3 then
691+
debounced_load_more()
692+
end
693+
end
694+
end,
695+
})
702696
end
703697

704698
---Clear the output buffer and all namespaces.

lua/opencode/ui/renderer.lua

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ 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+
local LAZYRENDER_EST_LINES_PER_MSG = 5
16+
local LAZYRENDER_VIEWPORT_BUFFER = 1.5
17+
18+
---Calculate how many messages to render initially based on window height.
19+
---@return integer
20+
local function get_initial_render_count()
21+
local win = state.windows and state.windows.output_win
22+
if not win or not vim.api.nvim_win_is_valid(win) then
23+
return math.huge -- no window: render all (tests, headless)
24+
end
25+
local ok, height = pcall(vim.api.nvim_win_get_height, win)
26+
if not ok or not height or height <= 0 then
27+
return math.huge
28+
end
29+
return math.ceil(height / LAZYRENDER_EST_LINES_PER_MSG * LAZYRENDER_VIEWPORT_BUFFER)
30+
end
31+
1532
---@return integer|nil
1633
local function get_max_rendered_messages()
1734
local limit = config.ui and config.ui.output and config.ui.output.max_messages
@@ -319,6 +336,9 @@ end
319336
---@param opts? { restore_model_from_messages?: boolean }
320337
function M._render_full_session_data(session_data, opts)
321338
opts = opts or {}
339+
-- Read before reset() clears it
340+
local lazy_limit = ctx.lazy_render_count
341+
local t_start = vim.uv.hrtime()
322342
M.reset()
323343
state.renderer.set_messages(session_data or {})
324344

@@ -329,6 +349,18 @@ function M._render_full_session_data(session_data, opts)
329349
local visible_messages, hidden_count = get_visible_session_messages(state.messages)
330350
local revert_index = get_revert_index(state.messages)
331351

352+
if lazy_limit == nil then
353+
local initial = get_initial_render_count()
354+
if #visible_messages > initial then
355+
lazy_limit = initial
356+
end
357+
end
358+
ctx.lazy_render_count = lazy_limit
359+
if lazy_limit and #visible_messages > lazy_limit then
360+
visible_messages = vim.list_slice(visible_messages, #visible_messages - lazy_limit + 1)
361+
end
362+
363+
local t_format_start = vim.uv.hrtime()
332364
flush.begin_bulk_mode()
333365

334366
if hidden_count > 0 then
@@ -376,8 +408,10 @@ function M._render_full_session_data(session_data, opts)
376408
events.on_part_updated({ part = revert_message.parts[1] })
377409
end
378410

411+
local t_format_end = vim.uv.hrtime()
379412
flush.flush()
380413
flush.end_bulk_mode()
414+
local t_flush_end = vim.uv.hrtime()
381415

382416
if opts.restore_model_from_messages then
383417
require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true })
@@ -397,26 +431,73 @@ function M.render_from_cache(session_data)
397431
if not output_window.mounted() or not state.api_client then
398432
return
399433
end
400-
M._render_full_session_data(session_data, {
401-
restore_model_from_messages = true,
402-
})
434+
M._render_full_session_data(session_data, {
435+
restore_model_from_messages = true,
436+
})
403437
local active_session = state.active_session
404438
if active_session and active_session.id then
405439
require('opencode.ui.question_window').restore_pending_question(active_session.id)
406440
permission_window.restore_pending_permissions(active_session.id)
407441
end
408442
end
409443

444+
---Load more older messages into the output buffer.
445+
---Called when user scrolls to the top of the output window.
446+
---@return boolean Whether more messages were loaded
447+
function M.load_more_messages()
448+
if not state.messages then
449+
return false
450+
end
451+
-- nil means no lazy limit → all messages already rendered
452+
if not ctx.lazy_render_count then
453+
return false
454+
end
455+
local total = #get_visible_session_messages(state.messages)
456+
if total == 0 then
457+
return false
458+
end
459+
if ctx.lazy_render_count >= total then
460+
return false
461+
end
462+
463+
-- Load another viewport's worth
464+
ctx.lazy_render_count = math.min(ctx.lazy_render_count + get_initial_render_count(), total)
465+
M.render_from_cache(state.messages)
466+
return true
467+
end
468+
469+
---Load all remaining messages and re-render.
470+
---Used when user explicitly navigates to the top (gg) to ensure
471+
---the full history is available for navigation and search.
472+
---@return boolean Whether any messages were loaded
473+
function M.load_all_messages()
474+
if not state.messages then
475+
return false
476+
end
477+
local total = #get_visible_session_messages(state.messages)
478+
if total == 0 then
479+
return false
480+
end
481+
-- nil means no lazy limit → all messages already rendered
482+
if not ctx.lazy_render_count or ctx.lazy_render_count >= total then
483+
return false
484+
end
485+
486+
ctx.lazy_render_count = total
487+
M.render_from_cache(state.messages)
488+
return true
489+
end
490+
410491
---Fetch the active session from the server and render it
411492
---@return Promise<OpencodeMessage[]>
412493
function M.render_full_session()
413494
if not output_window.mounted() or not state.api_client then
414495
return Promise.new():resolve(nil)
415496
end
416497
return fetch_session():and_then(function(session_data)
417-
M._render_full_session_data(session_data, {
418-
restore_model_from_messages = true,
419-
})
498+
M._render_full_session_data(session_data, {
499+
restore_model_from_messages = true,
500+
})
420501
local active_session = state.active_session
421502
if active_session and active_session.id then
422503
require('opencode.ui.question_window').restore_pending_question(active_session.id)

lua/opencode/ui/renderer/ctx.lua

Lines changed: 2 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.

tests/helpers.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ function M.replay_setup()
2525
end
2626

2727
renderer.reset()
28+
-- Ensure replay tests render all messages (lazy-render is always active)
29+
require('opencode.ui.renderer.ctx').lazy_render_count = math.huge
2830
permission_window.clear_all()
2931
question_window._clear_dialog()
3032
question_window._current_question = nil

0 commit comments

Comments
 (0)