Skip to content

Commit 7c22c24

Browse files
committed
feat(ui/renderer): add virtualized renderer, caching, and components
Refactor formatter to accept an explicit render context and expose render_* APIs while keeping legacy wrappers for backwards compatibility. Add renderer modules: backend, cache, components (message, part, overlays), measure, scheduler, session_view, viewport, and virtualize to support virtualized rendering and block-level caching/measurement. Add unit tests for formatter and renderer (cache, components, overlays).
1 parent 281e026 commit 7c22c24

15 files changed

Lines changed: 994 additions & 25 deletions

lua/opencode/ui/formatter.lua

Lines changed: 131 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,76 @@ M.separator = {
1717
'',
1818
}
1919

20+
local function default_win_width()
21+
local output_win = state.windows and state.windows.output_win
22+
if output_win and vim.api.nvim_win_is_valid(output_win) then
23+
return vim.api.nvim_win_get_width(output_win)
24+
end
25+
26+
if type(config.ui.window_width) == 'number' and config.ui.window_width > 1 then
27+
return config.ui.window_width
28+
end
29+
30+
return 80
31+
end
32+
33+
local function resolve_render_ctx(render_ctx)
34+
render_ctx = render_ctx or {}
35+
36+
local current_messages = render_ctx.messages
37+
if current_messages == nil then
38+
current_messages = state.messages or {}
39+
end
40+
41+
local permission_renderer = render_ctx.permission_renderer
42+
if permission_renderer == nil then
43+
permission_renderer = function(output)
44+
permission_window.format_display(output)
45+
end
46+
end
47+
48+
local question_renderer = render_ctx.question_renderer
49+
if question_renderer == nil then
50+
question_renderer = function(output)
51+
require('opencode.ui.question_window').format_display(output)
52+
end
53+
end
54+
55+
local show_reasoning_output = config.ui.output.tools.show_reasoning_output
56+
if render_ctx.show_reasoning_output ~= nil then
57+
show_reasoning_output = render_ctx.show_reasoning_output
58+
end
59+
60+
local show_debug_ids = config.debug.show_ids
61+
if render_ctx.show_debug_ids ~= nil then
62+
show_debug_ids = render_ctx.show_debug_ids
63+
end
64+
65+
local revert_info = state.active_session and state.active_session.revert or nil
66+
if render_ctx.revert_info ~= nil then
67+
revert_info = render_ctx.revert_info
68+
end
69+
70+
return {
71+
messages = current_messages,
72+
current_mode = render_ctx.current_mode ~= nil and render_ctx.current_mode or state.current_mode,
73+
show_reasoning_output = show_reasoning_output,
74+
show_debug_ids = show_debug_ids,
75+
win_width = render_ctx.win_width or default_win_width(),
76+
get_child_parts = render_ctx.get_child_parts,
77+
child_session_parts = render_ctx.child_session_parts,
78+
permission_renderer = permission_renderer,
79+
question_renderer = question_renderer,
80+
revert_info = revert_info,
81+
is_last_part = render_ctx.is_last_part == true,
82+
}
83+
end
84+
2085
---@param output Output
2186
---@param part OpencodeMessagePart
22-
function M._format_reasoning(output, part)
87+
---@param render_ctx? table
88+
function M._format_reasoning(output, part, render_ctx)
89+
render_ctx = resolve_render_ctx(render_ctx)
2390
local text = vim.trim(part.text or '')
2491

2592
local start_line = output:get_line_count() + 1
@@ -35,7 +102,7 @@ function M._format_reasoning(output, part)
35102

36103
format_utils.format_action(output, icons.get('reasoning'), title, '')
37104

38-
if config.ui.output.tools.show_reasoning_output and text ~= '' then
105+
if render_ctx.show_reasoning_output and text ~= '' then
39106
output:add_empty_line()
40107
output:add_lines(vim.split(text, '\n'))
41108
output:add_empty_line()
@@ -54,10 +121,12 @@ end
54121
---Format the revert callout with statistics
55122
---@param session_data OpencodeMessage[] All messages in the session
56123
---@param start_idx number Index of the message where revert occurred
124+
---@param render_ctx? table
57125
---@return Output output object representing the lines, extmarks, and actions
58-
function M._format_revert_message(session_data, start_idx)
126+
function M.render_revert_message(session_data, start_idx, render_ctx)
127+
render_ctx = resolve_render_ctx(render_ctx)
59128
local output = Output.new()
60-
local stats = format_utils.calculate_revert_stats(session_data, start_idx, state.active_session.revert)
129+
local stats = format_utils.calculate_revert_stats(session_data, start_idx, render_ctx.revert_info)
61130
local message_text = stats.messages == 1 and 'message' or 'messages'
62131
local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'
63132

@@ -98,6 +167,8 @@ function M._format_revert_message(session_data, start_idx)
98167
return output
99168
end
100169

170+
M._format_revert_message = M.render_revert_message
171+
101172
local function add_action(output, text, action_type, args, key, line)
102173
-- actions use api-indexing (e.g. 0 indexed)
103174
line = (line or output:get_line_count()) - 1
@@ -150,8 +221,10 @@ function M._format_error(output, message)
150221
end
151222

152223
---@param message OpencodeMessage
224+
---@param render_ctx? table
153225
---@return Output
154-
function M.format_message_header(message)
226+
function M.render_message_header(message, render_ctx)
227+
render_ctx = resolve_render_ctx(render_ctx)
155228
local output = Output.new()
156229

157230
output:add_lines(M.separator)
@@ -162,7 +235,7 @@ function M.format_message_header(message)
162235
local role_hl = 'OpencodeMessageRole' .. role:sub(1, 1):upper() .. role:sub(2)
163236
local model_text = message.info.modelID and ' ' .. message.info.modelID or ''
164237

165-
local debug_text = config.debug.show_ids and ' [' .. message.info.id .. ']' or ''
238+
local debug_text = render_ctx.show_debug_ids and ' [' .. message.info.id .. ']' or ''
166239

167240
local display_name
168241
if role == 'assistant' then
@@ -172,9 +245,10 @@ function M.format_message_header(message)
172245
else
173246
-- For the most recent assistant message, show current_mode if mode is missing
174247
-- This handles new messages that haven't been stamped yet
175-
local is_last_message = #state.messages == 0 or message.info.id == state.messages[#state.messages].info.id
176-
if is_last_message and state.current_mode and state.current_mode ~= '' then
177-
display_name = state.current_mode:upper()
248+
local messages = render_ctx.messages or {}
249+
local is_last_message = #messages == 0 or message.info.id == messages[#messages].info.id
250+
if is_last_message and render_ctx.current_mode and render_ctx.current_mode ~= '' then
251+
display_name = render_ctx.current_mode:upper()
178252
else
179253
display_name = 'ASSISTANT'
180254
end
@@ -222,16 +296,21 @@ function M.format_message_header(message)
222296
return output
223297
end
224298

299+
---@param message OpencodeMessage
300+
---@return Output
301+
function M.format_message_header(message)
302+
return M.render_message_header(message)
303+
end
304+
225305
---@param output Output Output object to write to
226306
---@param callout string Callout type (e.g., 'ERROR', 'TODO')
227307
---@param text string Callout text content
228308
---@param title? string Optional title for the callout
229-
function M._format_callout(output, callout, text, title)
309+
---@param render_ctx? table
310+
function M._format_callout(output, callout, text, title, render_ctx)
311+
render_ctx = resolve_render_ctx(render_ctx)
230312
title = title and title .. ' ' or ''
231-
local win_width = (state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win))
232-
and vim.api.nvim_win_get_width(state.windows.output_win)
233-
or config.ui.window_width
234-
or 80
313+
local win_width = render_ctx.win_width
235314
if #text > win_width - 4 then
236315
local ok, substituted = pcall(vim.fn.substitute, text, '\v(.{' .. (win_width - 8) .. '})', '\1\n', 'g')
237316
text = ok and substituted or text
@@ -470,10 +549,10 @@ end
470549
---Formats a single message part and returns the resulting output object
471550
---@param part OpencodeMessagePart The part to format
472551
---@param message? OpencodeMessage Optional message object to extract role and mentions from
473-
---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one
474-
---@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
552+
---@param render_ctx? { is_last_part?: boolean, get_child_parts?: fun(session_id: string): OpencodeMessagePart[]?, child_session_parts?: OpencodeMessagePart[]?, show_reasoning_output?: boolean, permission_renderer?: fun(output: Output), question_renderer?: fun(output: Output), win_width?: integer, messages?: OpencodeMessage[], current_mode?: string, show_debug_ids?: boolean }
475553
---@return Output
476-
function M.format_part(part, message, is_last_part, get_child_parts)
554+
function M.render_part(part, message, render_ctx)
555+
render_ctx = resolve_render_ctx(render_ctx)
477556
local output = Output.new()
478557

479558
if not message or not message.info or not message.info.role then
@@ -505,9 +584,24 @@ function M.format_part(part, message, is_last_part, get_child_parts)
505584
M._format_assistant_message(output, vim.trim(part.text), part.messageID)
506585
content_added = true
507586
elseif part.type == 'reasoning' then
508-
M._format_reasoning(output, part)
587+
M._format_reasoning(output, part, render_ctx)
509588
content_added = true
510589
elseif part.type == 'tool' then
590+
local get_child_parts = render_ctx.get_child_parts
591+
if render_ctx.child_session_parts ~= nil then
592+
local child_session_parts = render_ctx.child_session_parts
593+
local child_session_id = part.state and part.state.metadata and part.state.metadata.sessionId
594+
get_child_parts = function(session_id)
595+
if child_session_id and session_id == child_session_id then
596+
return child_session_parts
597+
end
598+
if type(render_ctx.get_child_parts) == 'function' then
599+
return render_ctx.get_child_parts(session_id)
600+
end
601+
return nil
602+
end
603+
end
604+
511605
M.format_tool(output, part, get_child_parts)
512606
content_added = true
513607
elseif part.type == 'patch' and part.hash then
@@ -516,27 +610,39 @@ function M.format_part(part, message, is_last_part, get_child_parts)
516610
end
517611
elseif role == 'system' then
518612
if part.type == 'permissions-display' then
519-
permission_window.format_display(output)
520-
content_added = true
613+
render_ctx.permission_renderer(output)
614+
content_added = output:get_line_count() > 0
521615
elseif part.type == 'questions-display' then
522-
local question_window = require('opencode.ui.question_window')
523-
question_window.format_display(output)
524-
content_added = true
616+
render_ctx.question_renderer(output)
617+
content_added = output:get_line_count() > 0
525618
end
526619
end
527620

528621
if content_added then
529622
output:add_empty_line()
530623
end
531624

532-
if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then
625+
if render_ctx.is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then
533626
local error = message.info.error
534627
local error_message = error.data and error.data.message or vim.inspect(error)
535-
M._format_callout(output, 'ERROR', error_message)
628+
M._format_callout(output, 'ERROR', error_message, nil, render_ctx)
536629
output:add_empty_line()
537630
end
538631

539632
return output
540633
end
541634

635+
---Formats a single message part and returns the resulting output object
636+
---@param part OpencodeMessagePart The part to format
637+
---@param message? OpencodeMessage Optional message object to extract role and mentions from
638+
---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one
639+
---@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
640+
---@return Output
641+
function M.format_part(part, message, is_last_part, get_child_parts)
642+
return M.render_part(part, message, {
643+
is_last_part = is_last_part,
644+
get_child_parts = get_child_parts,
645+
})
646+
end
647+
542648
return M
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
local M = {}
2+
3+
function M.apply_full(visible_render)
4+
return visible_render
5+
end
6+
7+
return M

0 commit comments

Comments
 (0)