Skip to content

Commit 3b7d2ef

Browse files
committed
feat(ui/renderer): flash-highlight updated lines in output window
1 parent 228b6be commit 3b7d2ef

6 files changed

Lines changed: 325 additions & 28 deletions

File tree

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ M.defaults = {
260260
enabled = false,
261261
capture_streamed_events = false,
262262
show_ids = true,
263+
highlight_updated_lines = false,
263264
quick_chat = {
264265
keep_session = false,
265266
set_active_session = false,

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@
208208
---@field enabled boolean
209209
---@field capture_streamed_events boolean
210210
---@field show_ids boolean
211+
---@field highlight_updated_lines boolean
211212
---@field quick_chat {keep_session: boolean, set_active_session: boolean}
212213

213214
---@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, 'OpencodeDebugUpdatedLine', { 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, 'OpencodeDebugUpdatedLine', { bg = '#4A3F17', default = true })
9395
end
9496
end
9597

lua/opencode/ui/output_window.lua

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ 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')
78

89
local _update_depth = 0
910
local _update_buf = nil
1011
local _scroll_tracking_suppressed = 0
12+
local _debug_flash_tick = 0
1113

1214
function M.with_suppressed_scroll_tracking(fn)
1315
if type(fn) ~= 'function' then
@@ -221,7 +223,7 @@ end
221223
---Clear output buf extmarks
222224
---@param start_line? integer Line to start clearing, defaults 0
223225
---@param end_line? integer Line to clear until, defaults to -1
224-
---@param clear_all? boolean If true, clears all extmarks in the buffer
226+
---@param clear_all? boolean If true, clears all namespaces in the range
225227
function M.clear_extmarks(start_line, end_line, clear_all)
226228
local windows = state.windows
227229
if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then
@@ -267,6 +269,46 @@ function M.set_extmarks(extmarks, line_offset)
267269
end
268270
end
269271

272+
---@param ranges { start_line: integer, end_line_exclusive: integer }[]
273+
function M.flash_updated_line_ranges(ranges)
274+
local windows = state.windows
275+
if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then
276+
return
277+
end
278+
279+
if type(ranges) ~= 'table' or vim.tbl_isempty(ranges) then
280+
return
281+
end
282+
283+
local output_buf = windows.output_buf
284+
_debug_flash_tick = _debug_flash_tick + 1
285+
local flash_tick = _debug_flash_tick
286+
287+
pcall(vim.api.nvim_buf_clear_namespace, output_buf, M.debug_namespace, 0, -1)
288+
289+
for _, range in ipairs(ranges) do
290+
local start_line = math.max(range.start_line or 0, 0)
291+
local end_line_exclusive = math.max(range.end_line_exclusive or start_line, start_line)
292+
for line = start_line, end_line_exclusive - 1 do
293+
pcall(vim.api.nvim_buf_set_extmark, output_buf, M.debug_namespace, line, 0, {
294+
line_hl_group = 'OpencodeDebugUpdatedLine',
295+
hl_eol = true,
296+
priority = 6000,
297+
})
298+
end
299+
end
300+
301+
vim.defer_fn(function()
302+
if flash_tick ~= _debug_flash_tick then
303+
return
304+
end
305+
306+
if state.windows and state.windows.output_buf and vim.api.nvim_buf_is_valid(state.windows.output_buf) then
307+
pcall(vim.api.nvim_buf_clear_namespace, state.windows.output_buf, M.debug_namespace, 0, -1)
308+
end
309+
end, 120)
310+
end
311+
270312
function M.focus_output(should_stop_insert)
271313
if not M.mounted() then
272314
return

lua/opencode/ui/renderer/buffer_ops.lua

Lines changed: 207 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ local output_window = require('opencode.ui.output_window')
55

66
local M = {}
77

8+
local function resolve_extmark(extmark)
9+
local actual_extmark = type(extmark) == 'function' and extmark() or extmark
10+
return vim.deepcopy(actual_extmark)
11+
end
12+
813
local function merge_output(target, source)
914
if not source then
1015
return
@@ -60,6 +65,181 @@ local function add_trailing_padding(output)
6065
return output
6166
end
6267

68+
local function build_output_with_padding(visible_render)
69+
return add_trailing_padding(M.flatten_visible_render(visible_render))
70+
end
71+
72+
local function resolve_line_extmarks(output, line_index)
73+
local resolved_extmarks = {}
74+
75+
for _, extmark in ipairs((output.extmarks or {})[line_index] or {}) do
76+
resolved_extmarks[#resolved_extmarks + 1] = resolve_extmark(extmark)
77+
end
78+
79+
return resolved_extmarks
80+
end
81+
82+
local function line_matches(previous_output, previous_line_index, next_output, next_line_index)
83+
return (previous_output.lines or {})[previous_line_index + 1] == (next_output.lines or {})[next_line_index + 1]
84+
and vim.deep_equal(
85+
resolve_line_extmarks(previous_output, previous_line_index),
86+
resolve_line_extmarks(next_output, next_line_index)
87+
)
88+
end
89+
90+
local function build_line_patches(previous_output, next_output)
91+
local previous_line_count = #(previous_output.lines or {})
92+
local next_line_count = #(next_output.lines or {})
93+
local shared_prefix = 0
94+
95+
while shared_prefix < previous_line_count and shared_prefix < next_line_count do
96+
if not line_matches(previous_output, shared_prefix, next_output, shared_prefix) then
97+
break
98+
end
99+
shared_prefix = shared_prefix + 1
100+
end
101+
102+
if shared_prefix == previous_line_count and shared_prefix == next_line_count then
103+
return {}
104+
end
105+
106+
local shared_suffix = 0
107+
while shared_suffix < (previous_line_count - shared_prefix) and shared_suffix < (next_line_count - shared_prefix) do
108+
if not line_matches(
109+
previous_output,
110+
previous_line_count - shared_suffix - 1,
111+
next_output,
112+
next_line_count - shared_suffix - 1
113+
) then
114+
break
115+
end
116+
shared_suffix = shared_suffix + 1
117+
end
118+
119+
return {
120+
{
121+
old_start_line = shared_prefix,
122+
old_end_line_exclusive = previous_line_count - shared_suffix,
123+
new_start_line = shared_prefix,
124+
new_end_line_exclusive = next_line_count - shared_suffix,
125+
},
126+
}
127+
end
128+
129+
local function slice_output(output, start_line, end_line_exclusive)
130+
local lines = {}
131+
for line_index = start_line + 1, end_line_exclusive do
132+
lines[#lines + 1] = (output.lines or {})[line_index] or ''
133+
end
134+
135+
local extmarks = {}
136+
for line_index, marks in pairs(output.extmarks or {}) do
137+
if line_index >= start_line and line_index < end_line_exclusive then
138+
local target_index = line_index - start_line
139+
extmarks[target_index] = {}
140+
for _, extmark in ipairs(marks) do
141+
local next_extmark = resolve_extmark(extmark)
142+
if next_extmark.end_row then
143+
next_extmark.end_row = next_extmark.end_row - start_line
144+
end
145+
extmarks[target_index][#extmarks[target_index] + 1] = next_extmark
146+
end
147+
end
148+
end
149+
150+
return {
151+
lines = lines,
152+
extmarks = extmarks,
153+
}
154+
end
155+
156+
local function apply_full_output(output)
157+
local updated_ranges = {
158+
{
159+
start_line = 0,
160+
end_line_exclusive = #(output.lines or {}),
161+
},
162+
}
163+
164+
if output_window.buffer_valid() then
165+
output_window.begin_update()
166+
output_window.clear_extmarks(0, -1, true)
167+
output_window.set_lines(output.lines or {})
168+
output_window.set_extmarks(output.extmarks)
169+
output_window.end_update()
170+
end
171+
172+
return updated_ranges
173+
end
174+
175+
local function apply_changed_line_patches(previous_output, next_output)
176+
local updated_ranges = {}
177+
local patches = build_line_patches(previous_output, next_output)
178+
local line_delta = 0
179+
180+
if output_window.buffer_valid() then
181+
output_window.begin_update()
182+
output_window.clear_extmarks(0, -1, true)
183+
for _, patch in ipairs(patches) do
184+
local start_line = patch.old_start_line + line_delta
185+
local old_end_line_exclusive = patch.old_end_line_exclusive + line_delta
186+
local replacement = slice_output(next_output, patch.new_start_line, patch.new_end_line_exclusive)
187+
188+
output_window.set_lines(replacement.lines or {}, start_line, old_end_line_exclusive)
189+
190+
updated_ranges[#updated_ranges + 1] = {
191+
start_line = start_line,
192+
end_line_exclusive = start_line + #(replacement.lines or {}),
193+
}
194+
195+
line_delta = line_delta + (#(replacement.lines or {}) - (patch.old_end_line_exclusive - patch.old_start_line))
196+
end
197+
output_window.set_extmarks(next_output.extmarks)
198+
output_window.end_update()
199+
end
200+
201+
return updated_ranges
202+
end
203+
204+
local function apply_block_patch(visible_render, patch)
205+
local replacement = flatten_block_range(visible_render.blocks or {}, patch.new_start_index, patch.new_end_index)
206+
if patch.include_trailing_padding then
207+
replacement = add_trailing_padding(replacement)
208+
end
209+
210+
local updated_ranges = {
211+
{
212+
start_line = patch.start_line,
213+
end_line_exclusive = patch.start_line + #(replacement.lines or {}),
214+
},
215+
}
216+
217+
if output_window.buffer_valid() then
218+
output_window.begin_update()
219+
output_window.clear_extmarks(patch.start_line, patch.old_end_line_exclusive, true)
220+
output_window.set_lines(replacement.lines or {}, patch.start_line, patch.old_end_line_exclusive)
221+
output_window.set_extmarks(replacement.extmarks, patch.start_line)
222+
output_window.end_update()
223+
end
224+
225+
return updated_ranges
226+
end
227+
228+
local function maybe_flash_updated_ranges(ranges)
229+
if not require('opencode.config').debug.highlight_updated_lines then
230+
return
231+
end
232+
233+
local flashable_ranges = {}
234+
for _, range in ipairs(ranges or {}) do
235+
if (range.end_line_exclusive or 0) > (range.start_line or 0) then
236+
flashable_ranges[#flashable_ranges + 1] = range
237+
end
238+
end
239+
240+
output_window.flash_updated_line_ranges(flashable_ranges)
241+
end
242+
63243
local function get_message_by_id(message_id)
64244
for _, message in ipairs(state.messages or {}) do
65245
if message and message.info and message.info.id == message_id then
@@ -113,48 +293,52 @@ end
113293
---@param visible_render { blocks?: RenderBlock[] }
114294
---@return Output
115295
function M.apply(visible_render)
116-
local output = add_trailing_padding(M.flatten_visible_render(visible_render))
117-
118-
if output_window.buffer_valid() then
119-
output_window.begin_update()
120-
output_window.set_lines(output.lines or {})
121-
output_window.clear_extmarks(0, -1, true)
122-
output_window.set_extmarks(output.extmarks)
123-
output_window.end_update()
124-
end
296+
local output = build_output_with_padding(visible_render)
297+
local updated_ranges = apply_full_output(output)
125298

126299
rebuild_render_state_from_blocks(visible_render and visible_render.blocks or {})
300+
maybe_flash_updated_ranges(updated_ranges)
127301
return output
128302
end
129303

130304
---@param plan { strategy: 'noop'|'full'|'patch', visible_render: table, patch?: table }
131305
---@return Output
132306
function M.apply_plan(plan)
133307
if plan and plan.strategy == 'noop' then
134-
return add_trailing_padding(M.flatten_visible_render(plan.visible_render))
308+
return build_output_with_padding(plan.visible_render)
135309
end
136310

137311
if not plan or plan.strategy ~= 'patch' or not plan.patch then
138-
return M.apply(plan and plan.visible_render or nil)
312+
local visible_render = plan and plan.visible_render or nil
313+
local next_output = build_output_with_padding(visible_render)
314+
local previous_visible_render = ctx.prev_visible_render
315+
local updated_ranges
316+
317+
if previous_visible_render then
318+
updated_ranges = apply_changed_line_patches(build_output_with_padding(previous_visible_render), next_output)
319+
else
320+
updated_ranges = apply_full_output(next_output)
321+
end
322+
323+
rebuild_render_state_from_blocks(visible_render and visible_render.blocks or {})
324+
maybe_flash_updated_ranges(updated_ranges)
325+
return next_output
139326
end
140327

141328
local visible_render = plan.visible_render or {}
142-
local patch = plan.patch
143-
local replacement = flatten_block_range(visible_render.blocks or {}, patch.new_start_index, patch.new_end_index)
144-
if patch.include_trailing_padding then
145-
replacement = add_trailing_padding(replacement)
146-
end
329+
local next_output = build_output_with_padding(visible_render)
330+
local previous_visible_render = ctx.prev_visible_render
331+
local updated_ranges
147332

148-
if output_window.buffer_valid() then
149-
output_window.begin_update()
150-
output_window.set_lines(replacement.lines or {}, patch.start_line, patch.old_end_line_exclusive)
151-
output_window.clear_extmarks(patch.start_line, patch.old_end_line_exclusive, false)
152-
output_window.set_extmarks(replacement.extmarks, patch.start_line)
153-
output_window.end_update()
333+
if previous_visible_render then
334+
updated_ranges = apply_changed_line_patches(build_output_with_padding(previous_visible_render), next_output)
335+
else
336+
updated_ranges = apply_block_patch(visible_render, plan.patch)
154337
end
155338

156339
rebuild_render_state_from_blocks(visible_render.blocks or {})
157-
return add_trailing_padding(M.flatten_visible_render(visible_render))
340+
maybe_flash_updated_ranges(updated_ranges)
341+
return next_output
158342
end
159343

160344
---@param prev_visible_render { blocks?: RenderBlock[] }?

0 commit comments

Comments
 (0)