Skip to content

Commit 475c93f

Browse files
committed
refactor(renderer): componentize renderer, add scheduler and revisioned cache
Introduce a structured renderer split into smaller components and a scheduling system to support incremental, throttled rendering: This refactor improves renderer performance and makes incremental updates more deterministic and testable.
1 parent adfd3ad commit 475c93f

20 files changed

Lines changed: 1405 additions & 219 deletions

lua/opencode/ui/renderer.lua

Lines changed: 112 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,99 @@ local Promise = require('opencode.promise')
77
local ctx = require('opencode.ui.renderer.ctx')
88
local buf = require('opencode.ui.renderer.buffer')
99
local events = require('opencode.ui.renderer.events')
10+
local session_view = require('opencode.ui.renderer.session_view')
11+
local virtualize = require('opencode.ui.renderer.virtualize')
12+
local backend = require('opencode.ui.renderer.backend')
13+
local overlays = require('opencode.ui.renderer.components.overlays')
14+
local scheduler = require('opencode.ui.renderer.scheduler')
1015

1116
local M = {}
1217

18+
local DEFAULT_MAX_RENDERED_MESSAGES = 80
19+
20+
local function update_current_message_state(messages)
21+
local current_message = nil
22+
local last_user_message = nil
23+
24+
for _, message in ipairs(messages or {}) do
25+
if message and message.info and message.info.id then
26+
current_message = message
27+
if message.info.role == 'user' then
28+
last_user_message = message
29+
end
30+
end
31+
end
32+
33+
state.renderer.set_current_message(current_message)
34+
state.renderer.set_last_user_message(last_user_message)
35+
end
36+
37+
local function find_revert_index(messages)
38+
if not state.active_session or not state.active_session.revert then
39+
return nil
40+
end
41+
42+
local revert_message_id = state.active_session.revert.messageID
43+
if not revert_message_id then
44+
return nil
45+
end
46+
47+
for i, message in ipairs(messages or {}) do
48+
if message and message.info and message.info.id == revert_message_id then
49+
return i
50+
end
51+
end
52+
53+
return nil
54+
end
55+
56+
local function build_full_session_output(messages)
57+
local revert_index = find_revert_index(messages)
58+
local max_messages = config.ui.output.max_rendered_messages or DEFAULT_MAX_RENDERED_MESSAGES
59+
local hidden_message_count = math.max(#(messages or {}) - max_messages, 0)
60+
local base_blocks = session_view.build_blocks(messages, {
61+
max_messages = max_messages,
62+
get_child_parts = function(session_id)
63+
return ctx.render_state:get_child_session_parts(session_id)
64+
end,
65+
include_revert_overlay = revert_index ~= nil,
66+
revert_index = revert_index,
67+
revert_info = state.active_session and state.active_session.revert or nil,
68+
})
69+
70+
local visible_render = virtualize.select_visible_blocks(base_blocks, {
71+
max_lines = config.ui.output.max_rendered_lines,
72+
})
73+
74+
if hidden_message_count > 0 then
75+
local hidden_block = overlays.render_hidden_history({
76+
hidden_count = hidden_message_count,
77+
})
78+
if hidden_block then
79+
table.insert(visible_render.blocks, 1, hidden_block)
80+
visible_render.visible_line_count = visible_render.visible_line_count + (hidden_block.line_count or 0)
81+
end
82+
end
83+
84+
visible_render.hidden_message_count = hidden_message_count
85+
86+
return visible_render
87+
end
88+
89+
local function apply_visible_render(visible_render, should_scroll)
90+
backend.apply_full(visible_render)
91+
92+
local visible_keys = {}
93+
for _, block in ipairs(visible_render.blocks or {}) do
94+
visible_keys[#visible_keys + 1] = block.key
95+
end
96+
ctx.prev_visible_keys = visible_keys
97+
98+
if should_scroll then
99+
M.scroll_to_bottom()
100+
end
101+
end
102+
13103
-- Expose event handlers on M so tests can call them directly and subscriptions
14104
-- can be stubbed cleanly (e.g. stub(renderer, '_render_full_session_data'))
15105
M.on_session_updated = events.on_session_updated
@@ -134,38 +224,42 @@ end
134224
function M._render_full_session_data(session_data)
135225
M.reset()
136226

137-
if not state.active_session or not state.messages then
227+
if not state.active_session then
138228
return
139229
end
140230

141-
local revert_index = nil
142-
local set_mode_from_messages = not state.current_model
143-
144-
for i, msg in ipairs(session_data) do
145-
if state.active_session.revert and state.active_session.revert.messageID == msg.info.id then
146-
revert_index = i
147-
end
148-
events.on_message_updated({ info = msg.info }, revert_index)
149-
for _, part in ipairs(msg.parts or {}) do
150-
events.on_part_updated({ part = part }, revert_index)
151-
end
231+
state.renderer.set_messages(session_data or {})
232+
if not state.messages then
233+
return
152234
end
153235

154-
if revert_index then
155-
buf.write_formatted_data(formatter._format_revert_message(state.messages, revert_index))
156-
end
236+
local set_mode_from_messages = not state.current_model
237+
238+
update_current_message_state(state.messages)
239+
local visible_render = build_full_session_output(state.messages)
240+
apply_visible_render(visible_render, true)
157241

158242
if set_mode_from_messages then
159243
set_model_and_mode_from_messages()
160244
end
161245

162-
M.scroll_to_bottom(true)
163-
164246
if config.hooks and config.hooks.on_session_loaded then
165247
pcall(config.hooks.on_session_loaded, state.active_session)
166248
end
167249
end
168250

251+
---@param snapshot? { full_render?: boolean, dirty_messages?: table<string, boolean>, dirty_parts?: table<string, boolean> }
252+
function M.perform_scheduled_render(snapshot)
253+
if not output_window.mounted() or not state.active_session or not state.messages then
254+
return
255+
end
256+
257+
local should_scroll = snapshot == nil or snapshot.full_render == true or next(snapshot.dirty_messages or {}) ~= nil
258+
update_current_message_state(state.messages)
259+
local visible_render = build_full_session_output(state.messages)
260+
apply_visible_render(visible_render, should_scroll)
261+
end
262+
169263
---Fetch the active session from the server and render it
170264
---@return Promise<OpencodeMessage[]>
171265
function M.render_full_session()
@@ -259,6 +353,7 @@ end
259353

260354
---Scroll to bottom after all queued events have been processed
261355
function M.on_emit_events_finished()
356+
scheduler.flush()
262357
M.scroll_to_bottom()
263358
end
264359

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
local buffer = require('opencode.ui.renderer.buffer')
2+
13
local M = {}
24

5+
---@param visible_render { blocks?: RenderBlock[] }
6+
function M.flatten_visible_render(visible_render)
7+
return buffer.flatten_visible_render(visible_render)
8+
end
9+
310
function M.apply_full(visible_render)
4-
return visible_render
11+
return buffer.apply_full(visible_render)
512
end
613

714
return M

lua/opencode/ui/renderer/buffer.lua

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local ctx = require('opencode.ui.renderer.ctx')
22
local state = require('opencode.state')
33
local formatter = require('opencode.ui.formatter')
44
local output_window = require('opencode.ui.output_window')
5+
local Output = require('opencode.ui.output')
56

67
local M = {}
78

@@ -13,6 +14,138 @@ local function has_actions(actions)
1314
return type(actions) == 'table' and #actions > 0
1415
end
1516

17+
local function merge_output(target, source)
18+
if not source then
19+
return
20+
end
21+
22+
local line_offset = target:get_line_count()
23+
target:add_lines(source.lines or {})
24+
25+
for line_idx, extmarks in pairs(source.extmarks or {}) do
26+
for _, extmark in ipairs(extmarks) do
27+
target:add_extmark(line_idx + line_offset, vim.deepcopy(extmark))
28+
end
29+
end
30+
31+
for _, action in ipairs(source.actions or {}) do
32+
local next_action = vim.deepcopy(action)
33+
if next_action.display_line ~= nil then
34+
next_action.display_line = next_action.display_line + line_offset
35+
end
36+
if next_action.range then
37+
next_action.range = {
38+
from = next_action.range.from + line_offset,
39+
to = next_action.range.to + line_offset,
40+
}
41+
end
42+
target:add_action(next_action)
43+
end
44+
end
45+
46+
local function get_message_by_id(message_id)
47+
for _, message in ipairs(state.messages or {}) do
48+
if message and message.info and message.info.id == message_id then
49+
return message
50+
end
51+
end
52+
53+
return nil
54+
end
55+
56+
local function index_message_block(block, line_cursor)
57+
local message_id = block.message_id
58+
if not message_id then
59+
return line_cursor
60+
end
61+
62+
local rendered_message = get_message_by_id(message_id)
63+
if not rendered_message then
64+
return line_cursor
65+
end
66+
67+
local header_line_count = block.header_line_count or 0
68+
local header_line_start = line_cursor
69+
local header_line_end = header_line_start + math.max(header_line_count - 1, 0)
70+
local message_line_end = line_cursor + math.max((block.line_count or 0) - 1, 0)
71+
72+
ctx.render_state:set_message(rendered_message, header_line_start, header_line_end)
73+
ctx.render_state:set_message_block(rendered_message, block, header_line_start, message_line_end)
74+
75+
local part_line_cursor = header_line_end + 1
76+
for _, part_block in ipairs(block.parts or {}) do
77+
local part = nil
78+
for _, candidate in ipairs(rendered_message.parts or {}) do
79+
if candidate.id == part_block.part_id then
80+
part = candidate
81+
break
82+
end
83+
end
84+
85+
if part then
86+
local part_line_start = part_line_cursor
87+
local part_line_end = part_line_start + (part_block.line_count or 0) - 1
88+
ctx.render_state:set_part(part, part_line_start, part_line_end)
89+
ctx.render_state:set_part_block(part, part_block, part_line_start, part_line_end)
90+
ctx.render_state:clear_actions(part.id)
91+
ctx.render_state:add_actions(part.id, vim.deepcopy(part_block.output.actions or {}), part_line_start)
92+
local rendered_part = ctx.render_state:get_part(part.id)
93+
if rendered_part then
94+
rendered_part.has_extmarks = part_block.output.extmarks and next(part_block.output.extmarks) ~= nil or false
95+
end
96+
part_line_cursor = part_line_end + 1
97+
end
98+
end
99+
100+
return message_line_end + 1
101+
end
102+
103+
local function rebuild_render_state_from_blocks(blocks)
104+
local line_cursor = 0
105+
106+
for _, block in ipairs(blocks or {}) do
107+
local line_count = block.line_count or 0
108+
local line_end = line_cursor + math.max(line_count - 1, 0)
109+
110+
ctx.render_state:set_block(block, line_cursor, line_end)
111+
112+
if block.kind == 'message' then
113+
line_cursor = index_message_block(block, line_cursor)
114+
else
115+
line_cursor = line_end + 1
116+
end
117+
end
118+
end
119+
120+
---@param visible_render { blocks?: RenderBlock[] }
121+
---@return Output
122+
function M.flatten_visible_render(visible_render)
123+
local output = Output.new()
124+
125+
for _, block in ipairs((visible_render and visible_render.blocks) or {}) do
126+
merge_output(output, block.output)
127+
end
128+
129+
return output
130+
end
131+
132+
---@param visible_render { blocks?: RenderBlock[] }
133+
---@return Output
134+
function M.apply_full(visible_render)
135+
local output = M.flatten_visible_render(visible_render)
136+
137+
if output_window.buffer_valid() then
138+
output_window.begin_update()
139+
output_window.set_lines(output.lines or {})
140+
output_window.clear_extmarks(0, -1, true)
141+
output_window.set_extmarks(output.extmarks)
142+
output_window.end_update()
143+
end
144+
145+
rebuild_render_state_from_blocks(visible_render and visible_render.blocks or {})
146+
return output
147+
end
148+
16149
---@param old_lines string[]
17150
---@param new_lines string[]
18151
---@return integer, integer

0 commit comments

Comments
 (0)