Skip to content

Commit 228b6be

Browse files
committed
feat(ui/renderer): decouple events from buffer ops
1 parent 281e026 commit 228b6be

42 files changed

Lines changed: 2971 additions & 951 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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: 5 additions & 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,
@@ -150,6 +151,10 @@ M.defaults = {
150151
show_reasoning_output = true,
151152
},
152153
always_scroll_to_bottom = false,
154+
viewport = {
155+
enabled = true,
156+
overscan = 8,
157+
},
153158
},
154159
questions = {
155160
use_vim_ui_select = false, -- If true, render questions with vim.ui.select instead of in the output buffer

lua/opencode/event_manager.lua

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,9 @@ function EventManager:_on_drained_events(events)
380380
if not config.ui.output.rendering.event_collapsing then
381381
for _, event in ipairs(normalized_events) do
382382
if event and event.type then
383-
self:emit(event.type, event.properties)
383+
if event.type ~= 'custom.emit_events.finished' then
384+
self:emit(event.type, event.properties)
385+
end
384386
else
385387
log.warn('Received event with missing type: %s', vim.inspect(event))
386388
end
@@ -432,7 +434,9 @@ function EventManager:_on_drained_events(events)
432434
for i = 1, #normalized_events do
433435
local event = collapsed_events[i]
434436
if event and event.type then
435-
self:emit(event.type, event.properties)
437+
if event.type ~= 'custom.emit_events.finished' then
438+
self:emit(event.type, event.properties)
439+
end
436440
elseif event then
437441
log.warn('Received collapsed event with missing type: %s', vim.inspect(event))
438442
end

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/dialog.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,11 @@ function Dialog:format_dialog(output, config)
260260

261261
local end_line = output:get_line_count()
262262

263+
output:add_line('')
264+
263265
if config.border_hl then
264266
formatter.add_vertical_border(output, start_line + 1, end_line, config.border_hl, -2)
265267
end
266-
267-
output:add_line('')
268268
end
269269

270270
---Format options list with selection indicator

lua/opencode/ui/formatter.lua

Lines changed: 148 additions & 32 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,19 +235,16 @@ 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
169242
local mode = message.info.mode
170243
if mode and mode ~= '' then
171244
display_name = mode:upper()
172245
else
173-
-- For the most recent assistant message, show current_mode if mode is missing
174-
-- 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()
246+
if render_ctx.current_mode and render_ctx.current_mode ~= '' then
247+
display_name = render_ctx.current_mode:upper()
178248
else
179249
display_name = 'ASSISTANT'
180250
end
@@ -222,16 +292,21 @@ function M.format_message_header(message)
222292
return output
223293
end
224294

295+
---@param message OpencodeMessage
296+
---@return Output
297+
function M.format_message_header(message)
298+
return M.render_message_header(message)
299+
end
300+
225301
---@param output Output Output object to write to
226302
---@param callout string Callout type (e.g., 'ERROR', 'TODO')
227303
---@param text string Callout text content
228304
---@param title? string Optional title for the callout
229-
function M._format_callout(output, callout, text, title)
305+
---@param render_ctx? table
306+
function M._format_callout(output, callout, text, title, render_ctx)
307+
render_ctx = resolve_render_ctx(render_ctx)
230308
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
309+
local win_width = render_ctx.win_width
235310
if #text > win_width - 4 then
236311
local ok, substituted = pcall(vim.fn.substitute, text, '\v(.{' .. (win_width - 8) .. '})', '\1\n', 'g')
237312
text = ok and substituted or text
@@ -259,17 +334,12 @@ function M._format_user_prompt(output, text, message)
259334

260335
local end_line = output:get_line_count()
261336

262-
local end_line_extmark_offset = 0
263-
264337
local mentions = {}
265338
if message and message.parts then
266339
-- message.parts will only be filled out on a re-render
267340
-- we need to collect the mentions here
268341
for _, part in ipairs(message.parts) do
269342
if part.type == 'file' then
270-
-- we're rerendering this part and we have files, the space after the user prompt
271-
-- also needs an extmark
272-
end_line_extmark_offset = 1
273343
if part.source and part.source.text then
274344
table.insert(mentions, part.source.text)
275345
end
@@ -285,6 +355,25 @@ function M._format_user_prompt(output, text, message)
285355
mention.highlight_mentions_in_output(output, text, mentions, start_line)
286356
end
287357

358+
local end_line_extmark_offset = 0
359+
if message and message.parts then
360+
local has_file_parts = false
361+
local last_content_idx = 0
362+
local text_idx = 0
363+
for i, part in ipairs(message.parts) do
364+
if part.type == 'file' then
365+
has_file_parts = true
366+
last_content_idx = i
367+
elseif part.type == 'text' and not part.synthetic then
368+
text_idx = i
369+
last_content_idx = i
370+
end
371+
end
372+
if has_file_parts and text_idx > 0 and text_idx == last_content_idx then
373+
end_line_extmark_offset = 1
374+
end
375+
end
376+
288377
M.add_vertical_border(output, start_line, end_line + end_line_extmark_offset, 'OpencodeMessageRoleUser', -3)
289378
end
290379

@@ -470,10 +559,10 @@ end
470559
---Formats a single message part and returns the resulting output object
471560
---@param part OpencodeMessagePart The part to format
472561
---@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[]?
562+
---@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 }
475563
---@return Output
476-
function M.format_part(part, message, is_last_part, get_child_parts)
564+
function M.render_part(part, message, render_ctx)
565+
render_ctx = resolve_render_ctx(render_ctx)
477566
local output = Output.new()
478567

479568
if not message or not message.info or not message.info.role then
@@ -505,9 +594,24 @@ function M.format_part(part, message, is_last_part, get_child_parts)
505594
M._format_assistant_message(output, vim.trim(part.text), part.messageID)
506595
content_added = true
507596
elseif part.type == 'reasoning' then
508-
M._format_reasoning(output, part)
597+
M._format_reasoning(output, part, render_ctx)
509598
content_added = true
510599
elseif part.type == 'tool' then
600+
local get_child_parts = render_ctx.get_child_parts
601+
if render_ctx.child_session_parts ~= nil then
602+
local child_session_parts = render_ctx.child_session_parts
603+
local child_session_id = part.state and part.state.metadata and part.state.metadata.sessionId
604+
get_child_parts = function(session_id)
605+
if child_session_id and session_id == child_session_id then
606+
return child_session_parts
607+
end
608+
if type(render_ctx.get_child_parts) == 'function' then
609+
return render_ctx.get_child_parts(session_id)
610+
end
611+
return nil
612+
end
613+
end
614+
511615
M.format_tool(output, part, get_child_parts)
512616
content_added = true
513617
elseif part.type == 'patch' and part.hash then
@@ -516,27 +620,39 @@ function M.format_part(part, message, is_last_part, get_child_parts)
516620
end
517621
elseif role == 'system' then
518622
if part.type == 'permissions-display' then
519-
permission_window.format_display(output)
520-
content_added = true
623+
render_ctx.permission_renderer(output)
624+
content_added = output:get_line_count() > 0
521625
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
626+
render_ctx.question_renderer(output)
627+
content_added = output:get_line_count() > 0
525628
end
526629
end
527630

528631
if content_added then
529632
output:add_empty_line()
530633
end
531634

532-
if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then
635+
if render_ctx.is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then
533636
local error = message.info.error
534637
local error_message = error.data and error.data.message or vim.inspect(error)
535-
M._format_callout(output, 'ERROR', error_message)
638+
M._format_callout(output, 'ERROR', error_message, nil, render_ctx)
536639
output:add_empty_line()
537640
end
538641

539642
return output
540643
end
541644

645+
---Formats a single message part and returns the resulting output object
646+
---@param part OpencodeMessagePart The part to format
647+
---@param message? OpencodeMessage Optional message object to extract role and mentions from
648+
---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one
649+
---@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
650+
---@return Output
651+
function M.format_part(part, message, is_last_part, get_child_parts)
652+
return M.render_part(part, message, {
653+
is_last_part = is_last_part,
654+
get_child_parts = get_child_parts,
655+
})
656+
end
657+
542658
return M

lua/opencode/ui/formatter/tools/task.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ function M.format(output, part, get_child_parts)
7979
type = 'select_child_session',
8080
args = {},
8181
key = 'S',
82-
display_line = start_line - 1,
83-
range = { from = start_line, to = end_line },
82+
display_line = start_line,
83+
range = { from = start_line + 1, to = end_line + 1 },
8484
})
8585
end
8686

lua/opencode/ui/output.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ function Output:add_extmark(idx, extmark)
102102
if not self.extmarks[idx] then
103103
self.extmarks[idx] = {}
104104
end
105+
105106
table.insert(self.extmarks[idx], extmark)
106107
end
107108

0 commit comments

Comments
 (0)