Skip to content

perf(render): fix lazy-render and fold performance for large sessions#393

Open
jensenojs wants to merge 4 commits into
sudo-tee:mainfrom
jensenojs:fix/render-perf
Open

perf(render): fix lazy-render and fold performance for large sessions#393
jensenojs wants to merge 4 commits into
sudo-tee:mainfrom
jensenojs:fix/render-perf

Conversation

@jensenojs
Copy link
Copy Markdown
Contributor

Problem

2786-message sessions take ~36 seconds to render. Two root causes:

  1. set_folds() uses cursor() + normal! zc/zo per fold, each triggering a full-screen redraw (~90ms/fold × 392 folds)
  2. _render_full_session_data() renders all messages on initial load

Closes #392

Changes

Fold performance (36s → 3s)

  • Replace vim.fn.cursor() + normal! zc/zo with :{from},{to}fold and :{from},{to}foldopen! — Ex commands don't move the cursor, zero screen redraws
  • Switch to foldmethod=manual before creating folds and stay there — prevents foldexpr recalculation for every buffer line (~4.6s on 87K lines)
  • Add line count validation to prevent E16: Invalid range errors

Lazy render (3s → ~10ms)

  • Only render enough messages to fill the output window on initial load (~1.5× viewport height)
  • Load more on scroll-to-top via WinScrolled autocmd
  • opts.lazy = true explicit opt-in — tests and headless environments render all messages
  • 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

Phase Before After
Fold creation ~35,266ms ~0.8ms
Total render ~36,000ms ~10ms
E2E (incl. async fetch) ~36,000ms ~1,875ms

Measured on a 2786-msg / 87K-line session

Testing

7 new test cases in tests/unit/renderer_lazy_spec.lua:

  • Renders all messages when lazy=false
  • Renders limited messages when ctx.lazy_render_count is set
  • Preserves lazy_render_count increment across render reset (exposes the core bug)
  • load_more_messages increments rendered message count
  • load_more_messages returns false when all/no messages loaded
  • No INFO-level debug notifications during rendering

All 110 existing tests pass.

@sudo-tee
Copy link
Copy Markdown
Owner

Thanks for the PR.

I will have a look at soon, I will be out for the weekend. So I will look at it at the start of the next week

@jensenojs
Copy link
Copy Markdown
Contributor Author

I want to go out and have fun too. Have a nice weekend!

Comment thread lua/opencode/ui/output_window.lua Outdated
Comment thread lua/opencode/ui/renderer.lua Outdated
@sudo-tee
Copy link
Copy Markdown
Owner

sudo-tee commented Jun 1, 2026

Awesome, I will do some testing on this throughout the day and let you know. Since this is a significant change in the rendering, I want to test it a little bit.

@sudo-tee
Copy link
Copy Markdown
Owner

sudo-tee commented Jun 1, 2026

@jensenojs

Thanks for the fix, while we are here can we set foldchars

  window_options.set_window_option('fillchars', 'fold:-,foldopen:-,foldclose:+,foldsep:│', windows.output_win)

otherwise we see the fold number which looks a bit odd

image


---@return integer|nil
local function get_max_rendered_messages()
local limit = config.ui and config.ui.output and config.ui.output.max_messages
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that we should combine the max_message behavior with the lazy one, otherwise it might make things confusing. And setting it to nil would essentially disable the lazy rendering ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I might be a bit busy these two days, and I actually lack some context in understanding the "max_message" feature. I can roughly grasp that the relationship between these two features might be a bit confusing, but I haven't figured it out yet.

For my simple profile, SQLite reading doesn't seem to be the bottleneck. I'll look into why that feature was introduced in the next day or two to make sure I haven't missed any considerations

@sudo-tee
Copy link
Copy Markdown
Owner

sudo-tee commented Jun 2, 2026

I did some initial test and it works well.

I do have some comments:

  • When loading more with the line action the cursor is moved back to end of the window
Screen.Recording.2026-06-02.074248.mp4
  • The config already expose a max_message and truncate the view, I feel we could combine the behaviors ? if not it might cause confision
8eee6d932e59fb6674a2112d983a9e0bfb3a7b5e7301692466503d5ea5cb6b82

Other than that it looks and feels good.

Thanks for your effort

jensenojs added 3 commits June 3, 2026 09:42
- 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 sudo-tee#392
@jensenojs
Copy link
Copy Markdown
Contributor Author

lazy-render is no longer a user setting — it's always on. hidden_count only reflects max_messages truncation now.

Behavior changes:

  • gg loads all messages before jumping to top (full history searchable)
  • load_more preserves cursor position instead of jumping to bottom
  • Search scope is rendered content (after gg or scrolling up = everything)
    Cursor bug: fixed.

@jemag would be great if you could test this branch — i am not sure if max_messages is still needed for performance on your setup. Rendering should be much faster now.

@jensenojs jensenojs requested a review from sudo-tee June 3, 2026 03:34
- 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
@sudo-tee
Copy link
Copy Markdown
Owner

sudo-tee commented Jun 3, 2026

Thanks for the fixes.

I got an error when using the gg keymap though

I also can't reach the top with the arrow keys or the scroll wheel nor gg

I don;t have any max_messages set, If I set it I still cant reach the top after loadig more

E5108: Lua: ...ts/_nvim/opencode.nvim/lua/opencode/ui/output_window.lua:462: E565: Not allowed to change text or change window
stack traceback:
	[C]: in function 'nvim_buf_set_lines'
	...ts/_nvim/opencode.nvim/lua/opencode/ui/output_window.lua:462: in function 'set_lines'
	...ts/_nvim/opencode.nvim/lua/opencode/ui/output_window.lua:696: in function 'clear'
	...rojects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:265: in function 'reset'
	...rojects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:342: in function '_render_full_session_data'
	...rojects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:434: in function 'render_from_cache'
	...rojects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:484: in function 'load_all_messages'
	...ts/_nvim/opencode.nvim/lua/opencode/ui/output_window.lua:602: in function <...ts/_nvim/opencode.nvim/lua/opencode/ui/output_window.lua:600>

I did initial debugging and it seems like the debounced_load_more is never called because the cursor often cursor at the top is something like 8 or 10 and not 3 or less.. If I put 10 the scrolling works, but I still get the gg error.

It seems to be an error because nvim does not allow buffer manipulation during expression evaluation.

changing the keymap to somethingl.ike this seems to work properly

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 })

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rendering large sessions is extremely slow (>30s for ~2800 messages)

2 participants