Tool call streaming flicker: why we use manual folds with anchor pads #212
Replies: 2 comments
Update: two more dead endsFound making manual folds actually persist across panel close/reopen.
|
Update: folds while the widget is hiddenAfter the previous fix, folds survived hide/show. But folds created Why
What docs/community solutions said (and why they did not fit)
What actually worksPer Solution: a per- Hard invariant: exactly one window on the chat buffer at a time.
Verified empiricallyTwo windows on the same buffer concurrently break the snapshot — only Two non-obvious traps from this work
SummaryManual folds are window-local, but Neovim's per-buffer last-used |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Tool call streaming flicker: why we use manual folds with anchor pads
Notes from #210. Posting because the dead ends are non-obvious.
Symptom
Streaming tool call updates flickered the chat buffer up and down in auto mode. Worst when a body crossed the fold threshold for the first time.
Two interacting causes
nvim_buf_set_linesshifts topline by buffer-line delta to keep the cursor visually anchored.zbrecomputes topline by screen-row math. Withwrap=on(chat needs it), one buffer line can wrap to 30+ screen rows. The two algorithms diverge.foldmethod=expr, foldexpr is lazy.set_linesonly evaluates a small neighborhood around the change.zbthen measures screen rows against an unfolded buffer state and lands on a wrong topline. ~10ms later foldexpr catches up, the fold materializes, andWinScrolledjumps the viewport. That jump is the flicker.What we shipped
Two changes:
Synchronous scroll. Replaced the scheduled
_auto_scrollwith a_capture_scroll(pre-mutation) +_apply_scroll(post-mutation, same tick) pair. Collapses the two-frame transition that made both topline values visible.Manual folds with anchor pads. Switched from
foldmethod=exprtofoldmethod=manual. Every tool call block is now laid out as:Body updates replace lines strictly between the anchors. The fold (created once via
:N,Nfoldwhen interior crosses the threshold) survives indefinitely because Vim grows manual folds when content is inserted inside their range. Replacing the entire fold range would destroy it; the anchors prevent that.Why not foldexpr + force recompute
We tried. The only Vim command that synchronously forces foldexpr recompute is
zX. It works but:foldlevel=0, closing any folds the user had opened withzoelsewhere.Manual folds avoid both costs.
Things that look like they should work but do not
All disproven empirically with headless tests counting foldexpr calls:
vim.fn.foldlevel(L)vim.fn.foldclosed(L)vim.wo.foldexpr = vim.wo.foldexpr:[start],[end] foldcloseznthenzNfoldenable. Preserves manual state. Does NOT recompute.winrestview({topline=...})zbrecomputes from cursor anyway, overwrites the restore.How avante and codecompanion avoid this
Both sidestep the problem by not using
foldexpr:nvim_win_set_cursor(winid, {last_line, last_col}). Cursor-follow handles topline.foldmethod=manual, folds created via:N,Nfoldonly after streaming completes. Never has live mid-stream fold transitions.Our case (live transitions on growing blocks) is the harder variant.
Sources
:help fold-expr,:help :fold(Neovim runtime docs)All reactions