Skip to content

Commit 5510dc2

Browse files
committed
perf(renderer): suppress TextChanged during flushes, reduce deepcopy and extmark allocs
- Wrap apply_pending and end_bulk_mode writes in eventignore='all' so render-markdown only fires from the explicit request_on_data_rendered call, not once per nvim_buf_set_lines during streaming or session load - set_lines noautocmd opts removed; bulk caller sets eventignore directly via begin_update/end_update, keeping the API clean - set_extmarks: skip deepcopy for marks with no start_col and no end_row offset needed (~99% of extmarks), eliminating 125k copies per session load - add_vertical_border: build extmark_opts once per call instead of per line - get_markdown_filetype: memoize vim.filetype.match results by filename - accumulate_bulk_extmarks helper deduplicates 3x copy-pasted extmark loop - Remove dead bulk_message_positions/bulk_part_positions tracking fields - Remove unused write_formatted_data export and dead append_part_now bulk branch - Hoist lines_equal/extmarks_equal out of format_message closure; replace vim.inspect comparison with vim.deep_equal
1 parent b0a3e0e commit 5510dc2

7 files changed

Lines changed: 423 additions & 141 deletions

File tree

lua/opencode/ui/formatter.lua

Lines changed: 123 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ function M._format_revert_message(session_data, start_idx)
6161
local message_text = stats.messages == 1 and 'message' or 'messages'
6262
local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'
6363

64-
output:add_lines(M.separator)
6564
output:add_line(
6665
string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text)
6766
)
@@ -95,12 +94,14 @@ function M._format_revert_message(session_data, start_idx)
9594
end
9695
end
9796
end
97+
98+
output:add_empty_line()
9899
return output
99100
end
100101

101102
local function add_action(output, text, action_type, args, key, line)
102103
-- actions use api-indexing (e.g. 0 indexed)
103-
line = (line or output:get_line_count()) - 1
104+
line = (line or output:get_line_count()) - 2
104105
output:add_action({
105106
text = text,
106107
type = action_type,
@@ -154,6 +155,11 @@ end
154155
function M.format_message_header(message)
155156
local output = Output.new()
156157

158+
if message.info and message.info.id == '__opencode_revert_message__' then
159+
output:add_lines(M.separator)
160+
return output
161+
end
162+
157163
output:add_lines(M.separator)
158164
local role = message.info.role or 'unknown'
159165
local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')
@@ -167,18 +173,13 @@ function M.format_message_header(message)
167173
local display_name
168174
if role == 'assistant' then
169175
local mode = message.info.mode
170-
if mode and mode ~= '' then
171-
display_name = mode:upper()
172-
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
176+
if mode and mode ~= '' then
177+
display_name = mode:upper()
178+
elseif state.current_mode and state.current_mode ~= '' then
177179
display_name = state.current_mode:upper()
178180
else
179181
display_name = 'ASSISTANT'
180182
end
181-
end
182183
else
183184
display_name = role:upper()
184185
end
@@ -291,11 +292,32 @@ end
291292
---@param output Output Output object to write to
292293
---@param part OpencodeMessagePart
293294
function M._format_selection_context(output, part)
295+
local part_message = part._message_context
294296
local json = context_module.decode_json_context(part.text or '', 'selection')
295297
if not json then
296298
return
297299
end
298-
local start_line = output:get_line_count()
300+
local start_line = output:get_line_count() + 1
301+
302+
if part_message and part_message.parts then
303+
for i, message_part in ipairs(part_message.parts) do
304+
if message_part.id == part.id then
305+
local previous_part = part_message.parts[i - 1]
306+
if previous_part and previous_part.type == 'text' and previous_part.synthetic then
307+
local has_selection = context_module.decode_json_context(previous_part.text or '', 'selection') ~= nil
308+
local has_cursor = context_module.decode_json_context(previous_part.text or '', 'cursor-data') ~= nil
309+
local diagnostics = context_module.decode_json_context(previous_part.text or '', 'diagnostics')
310+
local has_diagnostics = diagnostics and diagnostics.content and type(diagnostics.content) == 'table' and #diagnostics.content > 0
311+
312+
if has_selection or has_cursor or has_diagnostics then
313+
start_line = output:get_line_count()
314+
end
315+
end
316+
break
317+
end
318+
end
319+
end
320+
299321
output:add_lines(vim.split(json.content or '', '\n'))
300322
output:add_empty_line()
301323

@@ -359,6 +381,75 @@ function M._format_diagnostics_context(output, part)
359381
M.add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)
360382
end
361383

384+
local function get_visible_user_part_kind(part)
385+
if not part then
386+
return nil
387+
end
388+
389+
if part.type == 'file' and part.filename and part.filename ~= '' then
390+
return 'file'
391+
end
392+
393+
if part.type ~= 'text' or not part.text or part.text == '' then
394+
return nil
395+
end
396+
397+
if not part.synthetic then
398+
return 'text'
399+
end
400+
401+
if context_module.decode_json_context(part.text, 'selection') then
402+
return 'selection'
403+
end
404+
405+
if context_module.decode_json_context(part.text, 'cursor-data') then
406+
return 'cursor-data'
407+
end
408+
409+
local diagnostics = context_module.decode_json_context(part.text, 'diagnostics')
410+
if diagnostics and diagnostics.content and type(diagnostics.content) == 'table' and #diagnostics.content > 0 then
411+
return 'diagnostics'
412+
end
413+
414+
return nil
415+
end
416+
417+
local function get_user_part_neighbors(message, part)
418+
if not message or not message.parts or not part or not part.id then
419+
return nil, nil
420+
end
421+
422+
local current_index = nil
423+
for i, message_part in ipairs(message.parts) do
424+
if message_part.id == part.id then
425+
current_index = i
426+
break
427+
end
428+
end
429+
430+
if not current_index then
431+
return nil, nil
432+
end
433+
434+
local previous_kind = nil
435+
for i = current_index - 1, 1, -1 do
436+
previous_kind = get_visible_user_part_kind(message.parts[i])
437+
if previous_kind then
438+
break
439+
end
440+
end
441+
442+
local next_kind = nil
443+
for i = current_index + 1, #message.parts do
444+
next_kind = get_visible_user_part_kind(message.parts[i])
445+
if next_kind then
446+
break
447+
end
448+
end
449+
450+
return previous_kind, next_kind
451+
end
452+
362453
---Format and display the file path in the context
363454
---@param output Output Output object to write to
364455
---@param path string|nil File path
@@ -450,19 +541,14 @@ end
450541
---@param win_col number
451542
---@param text_hl_group? string Optional highlight group for the background/foreground of text lines
452543
function M.add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group)
544+
local extmark_opts = {
545+
virt_text = { { require('opencode.ui.icons').get('border'), hl_group } },
546+
virt_text_pos = 'overlay',
547+
virt_text_win_col = win_col,
548+
virt_text_repeat_linebreak = true,
549+
line_hl_group = text_hl_group or nil,
550+
}
453551
for line = start_line, end_line do
454-
local extmark_opts = {
455-
virt_text = { { require('opencode.ui.icons').get('border'), hl_group } },
456-
virt_text_pos = 'overlay',
457-
virt_text_win_col = win_col,
458-
virt_text_repeat_linebreak = true,
459-
}
460-
461-
-- Add line highlight if text_hl_group is provided
462-
if text_hl_group then
463-
extmark_opts.line_hl_group = text_hl_group
464-
end
465-
466552
output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]])
467553
end
468554
end
@@ -486,17 +572,30 @@ function M.format_part(part, message, is_last_part, get_child_parts)
486572
if role == 'user' then
487573
if part.type == 'text' and part.text then
488574
if part.synthetic == true then
575+
part._message_context = message
489576
M._format_selection_context(output, part)
490577
M._format_cursor_data_context(output, part)
491578
M._format_diagnostics_context(output, part)
579+
part._message_context = nil
492580
else
493581
M._format_user_prompt(output, vim.trim(part.text), message)
494582
content_added = true
495583
end
496584
elseif part.type == 'file' then
497585
local file_line = M._format_context_file(output, part.filename)
498586
if file_line then
499-
M.add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3)
587+
local previous_kind, next_kind = get_user_part_neighbors(message, part)
588+
local previous_is_context = previous_kind == 'selection'
589+
or previous_kind == 'cursor-data'
590+
or previous_kind == 'diagnostics'
591+
592+
if next_kind == 'text' or (previous_is_context and not next_kind) then
593+
M.add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3)
594+
elseif next_kind == 'file' then
595+
M.add_vertical_border(output, file_line, file_line + 1, 'OpencodeMessageRoleUser', -3)
596+
else
597+
M.add_vertical_border(output, file_line, file_line, 'OpencodeMessageRoleUser', -3)
598+
end
500599
content_added = true
501600
end
502601
end

lua/opencode/ui/output_window.lua

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -292,25 +292,23 @@ function M.set_lines(lines, start_line, end_line)
292292
start_line = start_line or 0
293293
end_line = end_line or -1
294294

295-
-- Avoid rewriting unchanged lines to prevent flashing/flicker when
296-
-- re-rendering formatted parts (e.g. markdown). Compare the target range
297-
-- with the existing buffer lines and skip the write when identical.
298-
local ok, existing = pcall(vim.api.nvim_buf_get_lines, buf, start_line, end_line, false)
299-
if ok and existing then
300-
local same = true
301-
if #existing ~= #lines then
302-
same = false
303-
else
295+
-- Skip identical content outside of batch mode to avoid unnecessary writes
296+
-- that cause flicker (e.g. when a markdown plugin re-renders an unchanged part).
297+
-- Inside begin_update/end_update the caller controls exactly what is written,
298+
-- so the check would be redundant and expensive.
299+
if _update_depth == 0 then
300+
local ok, existing = pcall(vim.api.nvim_buf_get_lines, buf, start_line, end_line, false)
301+
if ok and existing and #existing == #lines then
302+
local same = true
304303
for i = 1, #lines do
305304
if existing[i] ~= lines[i] then
306305
same = false
307306
break
308307
end
309308
end
310-
end
311-
312-
if same then
313-
return
309+
if same then
310+
return
311+
end
314312
end
315313
end
316314

@@ -355,19 +353,34 @@ function M.set_extmarks(extmarks, line_offset)
355353

356354
local output_buf = windows.output_buf
357355

358-
for line_idx, marks in pairs(extmarks) do
356+
local line_indices = vim.tbl_keys(extmarks)
357+
table.sort(line_indices)
358+
359+
for _, line_idx in ipairs(line_indices) do
360+
local marks = extmarks[line_idx]
361+
table.sort(marks, function(a, b)
362+
local ma = type(a) == 'function' and a() or a
363+
local mb = type(b) == 'function' and b() or b
364+
return (ma.priority or 0) > (mb.priority or 0)
365+
end)
366+
359367
for _, mark in ipairs(marks) do
360-
local actual_mark = type(mark) == 'function' and mark() or mark
368+
local m = type(mark) == 'function' and mark() or mark
361369
local target_line = line_offset + line_idx --[[@as integer]]
362-
if actual_mark.end_row then
363-
actual_mark.end_row = actual_mark.end_row + line_offset
364-
end
365-
local start_col = actual_mark.start_col
366-
if actual_mark.start_col then
367-
actual_mark.start_col = nil
370+
local start_col = m.start_col
371+
-- Only deepcopy when we need to mutate: start_col must be removed from the
372+
-- opts table, and end_row must be offset when line_offset is non-zero.
373+
-- The vast majority of extmarks (border virt_text) have neither field, so
374+
-- we avoid 100k+ deepcopy calls during a full session render.
375+
if start_col ~= nil or (m.end_row ~= nil and line_offset ~= 0) then
376+
m = vim.deepcopy(m)
377+
m.start_col = nil
378+
if m.end_row then
379+
m.end_row = m.end_row + line_offset
380+
end
368381
end
369-
---@cast actual_mark vim.api.keyset.set_extmark
370-
pcall(vim.api.nvim_buf_set_extmark, output_buf, M.namespace, target_line, start_col or 0, actual_mark)
382+
---@cast m vim.api.keyset.set_extmark
383+
pcall(vim.api.nvim_buf_set_extmark, output_buf, M.namespace, target_line, start_col or 0, m)
371384
end
372385
end
373386
end

0 commit comments

Comments
 (0)