Skip to content

Commit 3d5eddf

Browse files
committed
refactor(renderer): add scroll-tracking suppression & incremental message patching
- add output_window.with_suppressed_scroll_tracking and output_window.is_scroll_tracking_suppressed to manage nested suppression scopes - prevent autocmds and renderer logic from reacting to programmatic window calls by using suppression scopes - make config.ui.output.max_rendered_messages honor nil (show full history) and adjust full-session rendering truncation logic - implement nested message-level patching in renderer.buffer to patch only the changed parts inside a message block, preserving extmarks/actions and trailing padding - introduce trigger_on_data_rendered_now to run the render callback immediately where appropriate while keeping the existing debounced on_data_rendered - switch several scheduled renders to prefer incremental updates (avoid full renders) - add tests for cursor tracking, suppression scopes, nested message patching, and full-session rendering behavior
1 parent 8296e89 commit 3d5eddf

12 files changed

Lines changed: 370 additions & 39 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ require('opencode').setup({
234234
},
235235
output = {
236236
filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output')
237+
max_rendered_messages = nil, -- Maximum number of messages kept in the full-session render. Set to nil to show all messages, or a number to truncate older history.
237238
tools = {
238239
show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true)
239240
show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true)

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ M.defaults = {
139139
},
140140
output = {
141141
filetype = 'opencode_output',
142+
max_rendered_messages = nil,
142143
rendering = {
143144
markdown_debounce_ms = 250,
144145
on_data_rendered = nil,

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@
180180
---@field rendering OpencodeUIOutputRenderingConfig
181181
---@field always_scroll_to_bottom boolean
182182
---@field filetype string
183+
---@field max_rendered_messages? integer|nil
183184

184185
---@class OpencodeUIPickerConfig
185186
---@field snacks_layout? snacks.picker.layout.Config

lua/opencode/ui/output_window.lua

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ M.namespace = vim.api.nvim_create_namespace('opencode_output')
77

88
local _update_depth = 0
99
local _update_buf = nil
10+
local _scroll_tracking_suppressed = 0
11+
12+
function M.with_suppressed_scroll_tracking(fn)
13+
if type(fn) ~= 'function' then
14+
return nil
15+
end
16+
17+
_scroll_tracking_suppressed = _scroll_tracking_suppressed + 1
18+
local ok, result = pcall(fn)
19+
_scroll_tracking_suppressed = math.max(_scroll_tracking_suppressed - 1, 0)
20+
21+
if not ok then
22+
error(result)
23+
end
24+
25+
return result
26+
end
27+
28+
function M.is_scroll_tracking_suppressed()
29+
return _scroll_tracking_suppressed > 0
30+
end
1031

1132
---Begin a batch of buffer writes — toggle modifiable once for the whole batch.
1233
---Returns true if the batch was opened (buffer is valid). Must be paired with end_update().
@@ -313,6 +334,10 @@ function M.setup_autocmds(windows, group)
313334
group = group,
314335
buffer = windows.output_buf,
315336
callback = function()
337+
if M.is_scroll_tracking_suppressed() then
338+
return
339+
end
340+
316341
if not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then
317342
return
318343
end

lua/opencode/ui/renderer.lua

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ local scheduler = require('opencode.ui.renderer.scheduler')
1515

1616
local M = {}
1717

18-
local DEFAULT_MAX_RENDERED_MESSAGES = 80
18+
local trigger_on_data_rendered
19+
20+
local function with_suppressed_scroll_tracking(fn)
21+
return output_window.with_suppressed_scroll_tracking(fn)
22+
end
1923

2024
local function update_current_message_state(messages)
2125
local current_message = nil
@@ -74,7 +78,7 @@ end
7478
local function build_full_session_output(messages)
7579
local viewport_request = M.get_viewport_render_request()
7680
local revert_index = find_revert_index(messages)
77-
local max_messages = config.ui.output.max_rendered_messages or DEFAULT_MAX_RENDERED_MESSAGES
81+
local max_messages = config.ui.output.max_rendered_messages
7882

7983
-- When a revert is active, show messages before the revert point (the reverted message itself is hidden)
8084
local visible_messages = messages
@@ -85,7 +89,10 @@ local function build_full_session_output(messages)
8589
end
8690
end
8791

88-
local tail_hidden_message_count = math.max(#visible_messages - max_messages, 0)
92+
local tail_hidden_message_count = 0
93+
if type(max_messages) == 'number' and max_messages > 0 then
94+
tail_hidden_message_count = math.max(#visible_messages - max_messages, 0)
95+
end
8996
local base_blocks = session_view.build_blocks(visible_messages, {
9097
max_messages = viewport_request and nil or max_messages,
9198
get_child_parts = function(session_id)
@@ -146,17 +153,21 @@ local function restore_output_view(visible_render, restore_state)
146153
view.col = math.max(view.col or 0, 0)
147154
view.leftcol = 0
148155

149-
return pcall(vim.api.nvim_win_call, output_win, function()
150-
vim.fn.winrestview(view)
156+
return pcall(with_suppressed_scroll_tracking, function()
157+
vim.api.nvim_win_call(output_win, function()
158+
vim.fn.winrestview(view)
159+
end)
151160
end)
152161
end
153162

154163
local function apply_visible_render(visible_render, should_scroll, restore_state)
155-
if ctx.prev_visible_render and ctx.prev_visible_render.blocks then
156-
backend.apply_patch(ctx.prev_visible_render, visible_render)
157-
else
158-
backend.apply_full(visible_render)
159-
end
164+
with_suppressed_scroll_tracking(function()
165+
if ctx.prev_visible_render and ctx.prev_visible_render.blocks then
166+
backend.apply_patch(ctx.prev_visible_render, visible_render)
167+
else
168+
backend.apply_full(visible_render)
169+
end
170+
end)
160171

161172
local visible_keys = {}
162173
for _, block in ipairs(visible_render.blocks or {}) do
@@ -173,12 +184,16 @@ local function apply_visible_render(visible_render, should_scroll, restore_state
173184
}
174185

175186
if restore_output_view(visible_render, restore_state) then
187+
trigger_on_data_rendered()
176188
return
177189
end
178190

179191
if should_scroll then
180192
M.scroll_to_bottom()
193+
return
181194
end
195+
196+
trigger_on_data_rendered()
182197
end
183198

184199
function M.get_viewport_render_request()
@@ -226,7 +241,7 @@ end
226241
-- can be stubbed cleanly (e.g. stub(renderer, '_render_full_session_data'))
227242
M.on_session_updated = events.on_session_updated
228243

229-
local trigger_on_data_rendered = require('opencode.util').debounce(function()
244+
trigger_on_data_rendered = require('opencode.util').debounce(function()
230245
local cb_type = type(config.ui.output.rendering.on_data_rendered)
231246
if cb_type == 'boolean' then
232247
return
@@ -243,6 +258,23 @@ local trigger_on_data_rendered = require('opencode.util').debounce(function()
243258
end
244259
end, config.ui.output.rendering.markdown_debounce_ms or 250)
245260

261+
local function trigger_on_data_rendered_now()
262+
local cb_type = type(config.ui.output.rendering.on_data_rendered)
263+
if cb_type == 'boolean' then
264+
return
265+
end
266+
if not state.windows or not state.windows.output_buf or not state.windows.output_win then
267+
return
268+
end
269+
if cb_type == 'function' then
270+
pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)
271+
elseif vim.fn.exists(':RenderMarkdown') > 0 then
272+
vim.cmd(':RenderMarkdown')
273+
elseif vim.fn.exists(':Markview') > 0 then
274+
vim.cmd(':Markview render ' .. state.windows.output_buf)
275+
end
276+
end
277+
246278
---Reset all renderer state and clear the output buffer
247279
function M.reset()
248280
ctx:reset()
@@ -257,7 +289,7 @@ function M.reset()
257289
permission_window.clear_all()
258290
state.renderer.reset()
259291

260-
trigger_on_data_rendered()
292+
trigger_on_data_rendered_now()
261293
end
262294

263295
---Unsubscribe from all events and reset
@@ -377,7 +409,7 @@ function M.perform_scheduled_render(snapshot)
377409
return
378410
end
379411

380-
local should_scroll = snapshot == nil or snapshot.full_render == true or next(snapshot.dirty_messages or {}) ~= nil
412+
local should_scroll = snapshot == nil or snapshot.full_render == true or output_window.is_at_bottom()
381413
local viewport_request = M.get_viewport_render_request()
382414
local restore_state = viewport_request and {
383415
absolute_scroll_top = viewport_request.scroll_top,
@@ -447,17 +479,21 @@ function M.scroll_to_bottom(force)
447479
or prev_line_count == 0
448480
or config.ui.output.always_scroll_to_bottom
449481
or (function()
450-
local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, output_win)
451-
return ok_cursor and cursor and (cursor[1] >= prev_line_count or cursor[1] >= line_count)
452-
end)()
482+
local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, output_win)
483+
return ok_cursor and cursor and (cursor[1] >= prev_line_count or cursor[1] >= line_count)
484+
end)()
453485

454486
if should_scroll then
455-
local last_line = vim.api.nvim_buf_get_lines(output_buf, line_count - 1, line_count, false)[1] or ''
456-
vim.api.nvim_win_set_cursor(output_win, { line_count, #last_line })
457-
vim.api.nvim_win_call(output_win, function()
458-
vim.cmd('normal! zb')
487+
with_suppressed_scroll_tracking(function()
488+
local last_line = vim.api.nvim_buf_get_lines(output_buf, line_count - 1, line_count, false)[1] or ''
489+
vim.api.nvim_win_set_cursor(output_win, { line_count, #last_line })
490+
vim.api.nvim_win_call(output_win, function()
491+
vim.cmd('normal! zb')
492+
end)
459493
end)
460494
end
495+
496+
trigger_on_data_rendered()
461497
end
462498

463499
---Re-render the permission display when focus changes (updates shortcut hints)
@@ -491,7 +527,6 @@ function M.on_emit_events_finished()
491527
if not rendered then
492528
M.perform_scheduled_render({ full_render = true, dirty_messages = {}, dirty_parts = {} })
493529
end
494-
M.scroll_to_bottom()
495530
end
496531

497532
---Return all actions available at a given (0-indexed) line

0 commit comments

Comments
 (0)