Skip to content

Commit 93e6c3c

Browse files
authored
perf(renderer): avoid server round-trip on display toggles and incremental fold updates (#377)
* perf(renderer): avoid server round-trip on display toggles Replace ui.render_output() with ui.render_output_from_cache() for toggle_reasoning_output, toggle_tool_output, and toggle_max_messages. Instead of fetching the full session from the server on every toggle, re-render from state.messages that are already in memory. Falls back to the server fetch if no cached messages are available. * perf(renderer): incremental fold updates during streaming Replace set_all_folds() with update_part_folds() in the streaming update path to avoid rebuilding the entire fold list on every part change. Maintain part_folds and global_folds caches in ctx for incremental updates. During streaming, only the affected part's fold ranges change - its line_start stays the same. Update the cached entry for that part and flatten the per-part cache rather than re-iterating all parts in ctx.formatted_parts. * perf(renderer): skip vim.deepcopy in cache re-render path Add skip_deepcopy option to _render_full_session_data. The cache re-render path (toggle) already owns the session_data reference and executes synchronously, so deep copying is unnecessary overhead. * perf(renderer): remove unnecessary vim.deepcopy in _render_full_session_data All callers pass tables that are either fresh API responses (no other references) or are used synchronously (no concurrent mutation window). The deep copy served no actual protection - after storage, event handlers mutate state.messages in-place anyway. * fix(renderer): update fold ranges during append-only streaming append_part_now writes new lines but did not update the part's fold range, causing the fold's 'to' boundary to remain at its initial value. As content grew during streaming, new lines appeared outside the fold. Call update_part_folds after appending so the fold range tracks the actual content extent. * refactor(renderer): address review feedback - extract folds_equal, simplify update_part_folds loop - Extract folds_equal() helper function with nil-guard - Simplify update_part_folds loop by assigning ctx.part_folds first - Add 3 test cases for update_part_folds: absolute range computation, unchanged fold short-circuit, and multi-part fold merging * fix: sort folds by from value for deterministic output order pairs() iteration order is non-deterministic across LuaJIT builds, causing the merged fold list order to vary. Sort by from value so the output is always predictable. Also fix the merge test to use was_called_with with the sorted order.
1 parent 1a40067 commit 93e6c3c

6 files changed

Lines changed: 163 additions & 8 deletions

File tree

lua/opencode/commands/handlers/workflow.lua

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,14 +273,14 @@ function M.actions.toggle_tool_output()
273273
local action_text = config.ui.output.tools.show_output and 'Hiding' or 'Showing'
274274
vim.notify(action_text .. ' tool output display', vim.log.levels.INFO)
275275
config.values.ui.output.tools.show_output = not config.ui.output.tools.show_output
276-
ui.render_output()
276+
ui.render_output_from_cache()
277277
end
278278

279279
function M.actions.toggle_reasoning_output()
280280
local action_text = config.ui.output.tools.show_reasoning_output and 'Hiding' or 'Showing'
281281
vim.notify(action_text .. ' reasoning output display', vim.log.levels.INFO)
282282
config.values.ui.output.tools.show_reasoning_output = not config.ui.output.tools.show_reasoning_output
283-
ui.render_output()
283+
ui.render_output_from_cache()
284284
end
285285

286286
local original_max_messages = config.ui.output.max_messages
@@ -297,7 +297,7 @@ function M.actions.toggle_max_messages()
297297
local val_text = next_val == nil and 'none' or tostring(next_val)
298298
vim.notify(action_text .. ' message limit to ' .. val_text, vim.log.levels.INFO)
299299
config.values.ui.output.max_messages = next_val
300-
ui.render_output()
300+
ui.render_output_from_cache()
301301
end
302302

303303
M.actions.review = Promise.async(function(args)

lua/opencode/ui/renderer.lua

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ end
320320
function M._render_full_session_data(session_data, opts)
321321
opts = opts or {}
322322
M.reset()
323-
state.renderer.set_messages(vim.deepcopy(session_data or {}))
323+
state.renderer.set_messages(session_data or {})
324324

325325
if not state.active_session or not state.messages then
326326
return
@@ -390,6 +390,23 @@ function M._render_full_session_data(session_data, opts)
390390
end
391391
end
392392

393+
---Re-render from cached session data without a server round-trip.
394+
---Used for display-only changes (toggle folds, max_messages, etc.)
395+
---@param session_data OpencodeMessage[]
396+
function M.render_from_cache(session_data)
397+
if not output_window.mounted() or not state.api_client then
398+
return
399+
end
400+
M._render_full_session_data(session_data, {
401+
restore_model_from_messages = true,
402+
})
403+
local active_session = state.active_session
404+
if active_session and active_session.id then
405+
require('opencode.ui.question_window').restore_pending_question(active_session.id)
406+
permission_window.restore_pending_permissions(active_session.id)
407+
end
408+
end
409+
393410
---Fetch the active session from the server and render it
394411
---@return Promise<OpencodeMessage[]>
395412
function M.render_full_session()

lua/opencode/ui/renderer/buffer.lua

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -600,12 +600,12 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt
600600
set_part_extmark_state(part_id, formatted_data)
601601

602602
if formatted_data.fold_ranges and #formatted_data.fold_ranges > 0 then
603-
M.set_all_folds()
603+
M.update_part_folds(part_id)
604604
end
605605

606606
return true
607607
end
608-
608+
609609
local insert_at = get_part_insertion_line(part_id, message_id)
610610
if not insert_at then
611611
return false
@@ -635,22 +635,75 @@ end
635635

636636
function M.set_all_folds()
637637
local all_folds = {}
638+
ctx.part_folds = {}
638639
for part_id_iter, data in pairs(ctx.formatted_parts) do
639640
if data.fold_ranges then
640641
local cached_part = ctx.render_state:get_part(part_id_iter)
641642
if cached_part and cached_part.line_start then
643+
local part_abs_folds = {}
642644
for _, f in ipairs(data.fold_ranges) do
643-
table.insert(all_folds, {
645+
local abs = {
644646
from = cached_part.line_start + f.from - 1,
645647
to = cached_part.line_start + f.to - 1,
646-
})
648+
}
649+
table.insert(part_abs_folds, abs)
650+
table.insert(all_folds, abs)
647651
end
652+
ctx.part_folds[part_id_iter] = part_abs_folds
648653
end
649654
end
650655
end
656+
ctx.global_folds = all_folds
651657
output_window.set_folds(all_folds)
652658
end
653659

660+
local function folds_equal(a, b)
661+
if not a or not b then return false end
662+
if #a ~= #b then return false end
663+
for i = 1, #a do
664+
if a[i].from ~= b[i].from or a[i].to ~= b[i].to then return false end
665+
end
666+
return true
667+
end
668+
669+
---Update folds for a single part during streaming, avoiding a full rebuild.
670+
---@param part_id string
671+
function M.update_part_folds(part_id)
672+
local formatted_data = ctx.formatted_parts[part_id]
673+
if not formatted_data or not formatted_data.fold_ranges then
674+
ctx.part_folds[part_id] = nil
675+
M.set_all_folds()
676+
return
677+
end
678+
local cached_part = ctx.render_state:get_part(part_id)
679+
if not cached_part or not cached_part.line_start then
680+
return
681+
end
682+
683+
local new_folds = {}
684+
for _, f in ipairs(formatted_data.fold_ranges) do
685+
table.insert(new_folds, {
686+
from = cached_part.line_start + f.from - 1,
687+
to = cached_part.line_start + f.to - 1,
688+
})
689+
end
690+
691+
if folds_equal(ctx.part_folds[part_id], new_folds) then
692+
return
693+
end
694+
695+
ctx.part_folds[part_id] = new_folds
696+
local new_global = {}
697+
for _, pf in pairs(ctx.part_folds) do
698+
for _, f in ipairs(pf) do
699+
table.insert(new_global, f)
700+
end
701+
end
702+
table.sort(new_global, function(a, b) return a.from < b.from end)
703+
ctx.global_folds = new_global
704+
output_window.set_folds(new_global)
705+
end
706+
654707

655708
---@param part_id string
656709
---@param extra_lines string[]
@@ -676,6 +729,9 @@ function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_format
676729
apply_part_actions(part_id, formatted_data, cached.line_start)
677730
apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end)
678731
set_part_extmark_state(part_id, formatted_data)
732+
if formatted_data.fold_ranges then
733+
M.update_part_folds(part_id)
734+
end
679735
elseif has_extmarks(extra_extmarks) then
680736
output_window.set_extmarks(extra_extmarks, insert_at)
681737
end

lua/opencode/ui/renderer/ctx.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ local ctx = {
2929
bulk_buffer_lines = {},
3030
bulk_extmarks_by_line = {},
3131
bulk_folds = {},
32+
---@type {from: number, to: number}[]
33+
global_folds = {},
34+
---@type table<string, {from: number, to: number}[]>
35+
part_folds = {},
3236
}
3337

3438
---Reset all renderer caches and pending state.
@@ -50,6 +54,8 @@ function ctx:reset()
5054
}
5155
self.flush_scheduled = false
5256
self.markdown_render_scheduled = false
57+
self.global_folds = {}
58+
self.part_folds = {}
5359
self:bulk_reset()
5460
end
5561

lua/opencode/ui/ui.lua

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,19 @@ function M.clear_output()
484484
-- state.restore_points = {}
485485
end
486486

487+
---Re-render the output buffer from cached session data, avoiding a server round-trip.
488+
---Used for display-only toggles (show_reasoning_output, show_output, max_messages).
489+
---Falls back to render_output() if no cached messages are available.
490+
---@param opts? {force_scroll?: boolean}
491+
function M.render_output_from_cache(opts)
492+
local session_data = state.messages
493+
if not session_data or not next(session_data) then
494+
M.render_output(false, opts)
495+
return
496+
end
497+
renderer.render_from_cache(session_data)
498+
end
499+
487500
---Force a full rerender of the output buffer. Should be done synchronously if
488501
---called before submitting input or doing something that might generate events
489502
---from opencode

tests/unit/renderer_buffer_spec.lua

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,66 @@ describe('renderer.buffer extmarks', function()
126126
assert_called_before(call_order, 'clear_extmarks', 'set_lines')
127127
end)
128128
end)
129+
130+
describe('update_part_folds', function()
131+
local set_folds_stub
132+
133+
before_each(function()
134+
ctx:reset()
135+
set_folds_stub = stub(output_window, 'set_folds')
136+
ctx.global_folds = {}
137+
ctx.part_folds = {}
138+
end)
139+
140+
after_each(function()
141+
set_folds_stub:revert()
142+
ctx:reset()
143+
end)
144+
145+
it('computes absolute fold ranges for a single part', function()
146+
ctx.formatted_parts['part_a'] = {
147+
lines = { 'title', '', 'content', 'more' },
148+
fold_ranges = { { from = 1, to = 4 } },
149+
}
150+
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 14)
151+
152+
buffer.update_part_folds('part_a')
153+
154+
assert.stub(set_folds_stub).was_called_with({
155+
{ from = 10, to = 13 },
156+
})
157+
end)
158+
159+
it('skips set_folds when fold ranges have not changed', function()
160+
ctx.formatted_parts['part_a'] = {
161+
lines = { 'title', '', 'content', 'more' },
162+
fold_ranges = { { from = 1, to = 4 } },
163+
}
164+
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 14)
165+
166+
buffer.update_part_folds('part_a')
167+
set_folds_stub:clear()
168+
169+
buffer.update_part_folds('part_a')
170+
171+
assert.stub(set_folds_stub).was_not_called()
172+
end)
173+
174+
it('merges existing folds from other parts', function()
175+
ctx.part_folds['part_b'] = { { from = 5, to = 8 } }
176+
ctx.global_folds = { { from = 5, to = 8 } }
177+
178+
ctx.formatted_parts['part_a'] = {
179+
lines = { 'title', '', 'content', 'more' },
180+
fold_ranges = { { from = 1, to = 4 } },
181+
}
182+
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 14)
183+
184+
buffer.update_part_folds('part_a')
185+
186+
assert.stub(set_folds_stub).was_called_with({
187+
{ from = 5, to = 8 },
188+
{ from = 10, to = 13 },
189+
})
190+
end)
191+
end)

0 commit comments

Comments
 (0)