Skip to content

Commit 25b8ce3

Browse files
committed
feat(renderer): incremental rendering, markdown-on-idle, and changed-line highlights
1 parent 66e1e7a commit 25b8ce3

11 files changed

Lines changed: 394 additions & 49 deletions

File tree

lua/opencode/config.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ M.defaults = {
142142
rendering = {
143143
markdown_debounce_ms = 250,
144144
on_data_rendered = nil,
145+
markdown_on_idle = false,
146+
-- If set to a number, markdown rendering will be deferred while
147+
-- `state.user_message_count[session_id]` is greater than this value.
148+
-- If `nil`, the existing behavior is used (defer while > 0).
149+
markdown_on_idle_threshold = nil,
145150
event_throttle_ms = 40,
146151
event_collapsing = true,
147152
},
@@ -255,6 +260,8 @@ M.defaults = {
255260
enabled = false,
256261
capture_streamed_events = false,
257262
show_ids = true,
263+
highlight_changed_lines = true,
264+
highlight_changed_lines_timeout_ms = 120,
258265
quick_chat = {
259266
keep_session = false,
260267
set_active_session = false,

lua/opencode/core.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,8 @@ M.initialize_current_model = Promise.async(function()
537537
end)
538538

539539
M._on_user_message_count_change = Promise.async(function(_, new, old)
540+
require('opencode.ui.renderer.flush').flush_pending_on_data_rendered()
541+
540542
if config.hooks and config.hooks.on_done_thinking then
541543
local all_sessions = session.get_all_workspace_sessions():await()
542544
local done_sessions = vim.tbl_filter(function(s)

lua/opencode/types.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@
172172
---@class OpencodeUIOutputRenderingConfig
173173
---@field markdown_debounce_ms number
174174
---@field on_data_rendered (fun(buf: integer, win: integer)|boolean)|nil
175+
---@field markdown_on_idle boolean
175176
---@field event_throttle_ms number
176177
---@field event_collapsing boolean
177178

@@ -207,6 +208,8 @@
207208
---@field enabled boolean
208209
---@field capture_streamed_events boolean
209210
---@field show_ids boolean
211+
---@field highlight_changed_lines boolean
212+
---@field highlight_changed_lines_timeout_ms number
210213
---@field quick_chat {keep_session: boolean, set_active_session: boolean}
211214

212215
---@class OpencodeHooks

lua/opencode/ui/highlight.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ function M.setup()
4747
vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true })
4848
vim.api.nvim_set_hl(0, 'OpencodeQuestionBorder', { fg = '#E3F2FD', default = true })
4949
vim.api.nvim_set_hl(0, 'OpencodeQuestionTitle', { link = '@label', bold = true, default = true })
50+
vim.api.nvim_set_hl(0, 'OpencodeChangedLines', { bg = '#FFF3BF', default = true })
5051
else
5152
vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true })
5253
vim.api.nvim_set_hl(0, 'OpencodeBackground', { link = 'Normal', default = true })
@@ -90,6 +91,7 @@ function M.setup()
9091
vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true })
9192
vim.api.nvim_set_hl(0, 'OpencodeQuestionBorder', { fg = '#2B3A5A', default = true })
9293
vim.api.nvim_set_hl(0, 'OpencodeQuestionTitle', { link = '@label', bold = true, default = true })
94+
vim.api.nvim_set_hl(0, 'OpencodeChangedLines', { bg = '#3D3520', default = true })
9395
end
9496
end
9597

lua/opencode/ui/output_window.lua

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ local window_options = require('opencode.ui.window_options')
44

55
local M = {}
66
M.namespace = vim.api.nvim_create_namespace('opencode_output')
7+
M.debug_namespace = vim.api.nvim_create_namespace('opencode_output_debug')
8+
M.markdown_namespace = vim.api.nvim_create_namespace('opencode_output_markdown')
79

810
local _update_depth = 0
911
local _update_buf = nil
@@ -107,7 +109,12 @@ function M.is_at_bottom(win)
107109
end
108110

109111
function M.setup(windows)
110-
window_options.set_window_option('winhighlight', config.ui.window_highlight, windows.output_win, { save_original = true })
112+
window_options.set_window_option(
113+
'winhighlight',
114+
config.ui.window_highlight,
115+
windows.output_win,
116+
{ save_original = true }
117+
)
111118
window_options.set_window_option('wrap', true, windows.output_win, { save_original = true })
112119
window_options.set_window_option('linebreak', true, windows.output_win, { save_original = true })
113120
window_options.set_window_option('number', false, windows.output_win, { save_original = true })
@@ -117,6 +124,8 @@ function M.setup(windows)
117124
window_options.set_buffer_option('bufhidden', 'hide', windows.output_buf)
118125
window_options.set_buffer_option('buflisted', false, windows.output_buf)
119126
window_options.set_buffer_option('swapfile', false, windows.output_buf)
127+
window_options.set_buffer_option('undofile', false, windows.output_buf)
128+
window_options.set_buffer_option('undolevels', -1, windows.output_buf)
120129

121130
if config.ui.position ~= 'current' then
122131
window_options.set_window_option('winfixbuf', true, windows.output_win, { save_original = true })
@@ -188,6 +197,28 @@ function M.set_lines(lines, start_line, end_line)
188197
start_line = start_line or 0
189198
end_line = end_line or -1
190199

200+
-- Avoid rewriting unchanged lines to prevent flashing/flicker when
201+
-- re-rendering formatted parts (e.g. markdown). Compare the target range
202+
-- with the existing buffer lines and skip the write when identical.
203+
local ok, existing = pcall(vim.api.nvim_buf_get_lines, buf, start_line, end_line, false)
204+
if ok and existing then
205+
local same = true
206+
if #existing ~= #lines then
207+
same = false
208+
else
209+
for i = 1, #lines do
210+
if existing[i] ~= lines[i] then
211+
same = false
212+
break
213+
end
214+
end
215+
end
216+
217+
if same then
218+
return
219+
end
220+
end
221+
191222
if _update_depth == 0 then
192223
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
193224
vim.api.nvim_buf_set_lines(buf, start_line, end_line, false, lines)
@@ -246,6 +277,40 @@ function M.set_extmarks(extmarks, line_offset)
246277
end
247278
end
248279

280+
---@param start_line integer
281+
---@param end_line integer
282+
function M.highlight_changed_lines(start_line, end_line)
283+
local windows = state.windows
284+
if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then
285+
return
286+
end
287+
if not config.debug.highlight_changed_lines then
288+
return
289+
end
290+
291+
local buf = windows.output_buf
292+
local first = math.max(0, start_line)
293+
if end_line < start_line then
294+
return
295+
end
296+
local last = math.max(first, end_line)
297+
298+
vim.api.nvim_buf_clear_namespace(buf, M.debug_namespace, first, last + 1)
299+
for line = first, last do
300+
vim.api.nvim_buf_set_extmark(buf, M.debug_namespace, line, 0, {
301+
line_hl_group = 'OpencodeChangedLines',
302+
hl_eol = true,
303+
priority = 250,
304+
})
305+
end
306+
307+
vim.defer_fn(function()
308+
if vim.api.nvim_buf_is_valid(buf) then
309+
vim.api.nvim_buf_clear_namespace(buf, M.debug_namespace, first, last + 1)
310+
end
311+
end, config.debug.highlight_changed_lines_timeout_ms or 120)
312+
end
313+
249314
function M.focus_output(should_stop_insert)
250315
if not M.mounted() then
251316
return

lua/opencode/ui/renderer/buffer.lua

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,59 @@ local function has_actions(actions)
1212
return type(actions) == 'table' and #actions > 0
1313
end
1414

15+
local function unchanged_prefix_len(previous_formatted, formatted_data)
16+
local previous_lines = previous_formatted and previous_formatted.lines or {}
17+
local next_lines = formatted_data and formatted_data.lines or {}
18+
local prefix_len = 0
19+
20+
for i = 1, math.min(#previous_lines, #next_lines) do
21+
if previous_lines[i] ~= next_lines[i] then
22+
break
23+
end
24+
prefix_len = i
25+
end
26+
27+
return prefix_len
28+
end
29+
30+
local function slice_lines(lines, start_idx)
31+
local slice = {}
32+
for i = start_idx, #(lines or {}) do
33+
slice[#slice + 1] = lines[i]
34+
end
35+
return slice
36+
end
37+
38+
local function slice_extmarks(extmarks, start_line)
39+
local slice = {}
40+
for line_idx, marks in pairs(extmarks or {}) do
41+
if line_idx >= start_line + 1 then
42+
slice[line_idx - start_line] = vim.deepcopy(marks)
43+
end
44+
end
45+
return slice
46+
end
47+
48+
local function highlight_written_lines(start_line, lines)
49+
if #lines == 0 then
50+
return
51+
end
52+
output_window.highlight_changed_lines(start_line, start_line + #lines - 1)
53+
end
54+
55+
local function apply_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end)
56+
local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data)
57+
local clear_start = line_start + prefix_len
58+
local clear_end = math.max(old_line_end, new_line_end) + 1
59+
60+
output_window.clear_extmarks(clear_start, clear_end)
61+
62+
local extmarks = slice_extmarks(formatted_data.extmarks, prefix_len)
63+
if has_extmarks(extmarks) then
64+
output_window.set_extmarks(extmarks, clear_start)
65+
end
66+
end
67+
1568
local function get_message_insert_line(message_id)
1669
local rendered_message = ctx.render_state:get_message(message_id)
1770
if rendered_message and rendered_message.line_start then
@@ -80,6 +133,7 @@ end
80133

81134
local function write_at(lines, start_line, end_line)
82135
output_window.set_lines(lines, start_line, end_line)
136+
highlight_written_lines(start_line, lines)
83137
return {
84138
line_start = start_line,
85139
line_end = start_line + #lines - 1,
@@ -100,13 +154,7 @@ local function apply_part_actions(part_id, formatted_data, line_start)
100154
end
101155
end
102156

103-
local function apply_part_extmarks(part_id, formatted_data, line_start, line_end)
104-
output_window.clear_extmarks(line_start - 1, line_end + 1)
105-
106-
if has_extmarks(formatted_data.extmarks) then
107-
output_window.set_extmarks(formatted_data.extmarks, line_start)
108-
end
109-
157+
local function set_part_extmark_state(part_id, formatted_data)
110158
local part_data = ctx.render_state:get_part(part_id)
111159
if part_data then
112160
part_data.has_extmarks = has_extmarks(formatted_data.extmarks)
@@ -142,17 +190,19 @@ function M.find_part_by_call_id(call_id, message_id)
142190
return ctx.render_state:get_part_by_call_id(call_id, message_id)
143191
end
144192

145-
function M.upsert_message_now(message_id, formatted_data)
193+
function M.upsert_message_now(message_id, formatted_data, previous_formatted)
146194
local cached = ctx.render_state:get_message(message_id)
147195
if cached and cached.line_start and cached.line_end then
148-
output_window.clear_extmarks(cached.line_start, cached.line_end + 1)
149-
output_window.set_lines(formatted_data.lines, cached.line_start, cached.line_end + 1)
150-
if has_extmarks(formatted_data.extmarks) then
151-
output_window.set_extmarks(formatted_data.extmarks, cached.line_start)
152-
end
153-
154196
local old_line_end = cached.line_end
197+
local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data)
198+
local write_start = cached.line_start + prefix_len
199+
local lines_to_write = slice_lines(formatted_data.lines, prefix_len + 1)
200+
201+
output_window.set_lines(lines_to_write, write_start, cached.line_end + 1)
202+
highlight_written_lines(write_start, lines_to_write)
203+
155204
local new_line_end = cached.line_start + #formatted_data.lines - 1
205+
apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end)
156206
ctx.render_state:set_message(cached.message, cached.line_start, new_line_end)
157207

158208
local delta = new_line_end - old_line_end
@@ -178,18 +228,25 @@ function M.upsert_message_now(message_id, formatted_data)
178228
return false
179229
end
180230

181-
function M.upsert_part_now(part_id, message_id, formatted_data)
231+
function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatted)
182232
local cached = ctx.render_state:get_part(part_id)
183233
if cached and cached.line_start and cached.line_end then
184-
output_window.set_lines(formatted_data.lines, cached.line_start, cached.line_end + 1)
234+
local old_line_end = cached.line_end
235+
local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data)
236+
local write_start = cached.line_start + prefix_len
237+
local lines_to_write = slice_lines(formatted_data.lines, prefix_len + 1)
238+
239+
output_window.set_lines(lines_to_write, write_start, cached.line_end + 1)
240+
highlight_written_lines(write_start, lines_to_write)
185241

186242
local new_line_end = cached.line_start + #formatted_data.lines - 1
187243
apply_part_actions(part_id, formatted_data, cached.line_start)
188244

189245
if new_line_end ~= cached.line_end then
190246
ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end)
191247
end
192-
apply_part_extmarks(part_id, formatted_data, cached.line_start, new_line_end)
248+
apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end)
249+
set_part_extmark_state(part_id, formatted_data)
193250
return true
194251
end
195252

@@ -204,29 +261,35 @@ function M.upsert_part_now(part_id, message_id, formatted_data)
204261
ctx.render_state:shift_all(insert_at, #formatted_data.lines)
205262
ctx.render_state:set_part(part_data.part, range.line_start, range.line_end)
206263
apply_part_actions(part_id, formatted_data, range.line_start)
207-
apply_part_extmarks(part_id, formatted_data, range.line_start, range.line_end)
264+
if has_extmarks(formatted_data.extmarks) then
265+
output_window.set_extmarks(formatted_data.extmarks, range.line_start)
266+
end
267+
set_part_extmark_state(part_id, formatted_data)
208268
return true
209269
end
210270

211271
return false
212272
end
213273

214-
function M.append_part_now(part_id, extra_lines, extra_extmarks)
274+
function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_formatted)
215275
local cached = ctx.render_state:get_part(part_id)
216276
if not cached or not cached.line_start or not cached.line_end or #extra_lines == 0 then
217277
return false
218278
end
219279

220280
local insert_at = cached.line_end + 1
281+
local old_line_end = cached.line_end
221282
output_window.set_lines(extra_lines, insert_at, insert_at)
283+
highlight_written_lines(insert_at, extra_lines)
222284

223285
local new_line_end = cached.line_end + #extra_lines
224286
ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end)
225287

226288
local formatted_data = ctx.formatted_parts[part_id]
227289
if formatted_data then
228290
apply_part_actions(part_id, formatted_data, cached.line_start)
229-
apply_part_extmarks(part_id, formatted_data, cached.line_start, new_line_end)
291+
apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end)
292+
set_part_extmark_state(part_id, formatted_data)
230293
elseif has_extmarks(extra_extmarks) then
231294
output_window.set_extmarks(extra_extmarks, insert_at)
232295
end

lua/opencode/ui/renderer/ctx.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ local ctx = {
2626
removed_messages = {},
2727
},
2828
flush_scheduled = false,
29+
markdown_render_scheduled = false,
2930
}
3031

3132
function ctx:reset()
@@ -46,6 +47,7 @@ function ctx:reset()
4647
removed_messages = {},
4748
}
4849
self.flush_scheduled = false
50+
self.markdown_render_scheduled = false
4951
end
5052

5153
return ctx

0 commit comments

Comments
 (0)