Skip to content

Commit 05f31fb

Browse files
committed
feat: add streaming 'typing' reveal for the unified inline diff
Animate the unified inline diff with a natural character-by-character 'typing' reveal, opt-in via diff_opts.stream_reveal. - Incremental reveal: each tick appends only the new tail to the buffer and adds extmarks only for lines just fully revealed, so the work is O(n) in lines instead of O(n^2); earlier lines and their marks are never rewritten, keeping highlights correctly positioned. - Cancellable: a module-level reveal_gen generation counter aborts any in-flight animation (e.g. when a new diff replaces this buffer or the diff is torn down), preventing the stale-typing bug and diff accumulation (see #205). - Configurable: stream_reveal, stream_reveal_delay_ms (floor), and stream_reveal_chars_per_tick under diff_opts; per-tick cadence uses a randomized 400-1000 WPM feel. Stacks on #300 (the unified inline diff layout).
1 parent abc4832 commit 05f31fb

1 file changed

Lines changed: 128 additions & 3 deletions

File tree

lua/claudecode/diff_inline.lua

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ local ns = vim.api.nvim_create_namespace("claudecode_inline_diff")
1212
--- so it always stays open showing the file whose changes were reviewed.
1313
local CLAUDE_BUFFER_NAME = "ClaudeCodeBuffer"
1414

15+
--- Bumped whenever a reveal starts or the diff is torn down, so any in-flight
16+
--- streaming animation stops and does not mutate the buffer. Each call to
17+
--- `render_diff_buffer_streamed` captures the current value and bails out once it
18+
--- no longer matches, making the animation safely cancellable.
19+
local reveal_gen = 0
20+
1521
---Get the persistent ClaudeCodeBuffer, creating it on first use or resetting it for reuse.
1622
---The buffer is kept alive (`bufhidden = "hide"`) so it survives window/tab closes.
1723
---@return number buf The buffer handle
@@ -187,6 +193,113 @@ function M.render_diff_buffer(buf, lines, line_types)
187193
end
188194
end
189195

196+
---@param wpm number Words per minute
197+
---@return number milliseconds per character
198+
local function wpm_to_ms_per_char(wpm)
199+
return (60.0 / wpm / 5.0) * 1000.0
200+
end
201+
202+
--- Progressively reveal `lines` in `buf` with a natural "typing" cadence.
203+
---
204+
--- The reveal is *incremental*: each tick advances by `chars_per_tick` characters,
205+
--- appends only the new tail to the buffer (never rewriting earlier lines), and
206+
--- adds extmarks only for lines that just became fully revealed. This keeps the
207+
--- work O(n) in the number of lines instead of O(n²), and because earlier lines
208+
--- and their marks are never touched, they stay correctly positioned.
209+
---@param buf number Buffer handle
210+
---@param lines string[] Full diff lines (source of truth)
211+
---@param line_types string[] Parallel type array
212+
---@param opts table|nil { delay_ms:number, chars_per_tick:number }
213+
local function render_diff_buffer_streamed(buf, lines, line_types, opts)
214+
opts = opts or {}
215+
local chars_per_tick = math.max(1, opts.chars_per_tick or 1)
216+
local delay_floor_ms = math.max(0, opts.delay_ms or 0)
217+
218+
local full = table.concat(lines, "\n")
219+
local len = #full
220+
if len == 0 then
221+
vim.api.nvim_buf_set_option(buf, "modifiable", false)
222+
return
223+
end
224+
225+
reveal_gen = reveal_gen + 1
226+
local my_gen = reveal_gen
227+
228+
-- Mirror of the buffer content (1-based), mutated incrementally so we only ever
229+
-- rewrite the changed tail. Earlier lines and their extmarks are never touched.
230+
local buf_lines = { "" }
231+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, buf_lines)
232+
233+
local committed = 0
234+
local marked_count = 0
235+
236+
local function reveal_up_to(target_pos)
237+
target_pos = math.min(len, target_pos)
238+
if target_pos <= committed then
239+
return
240+
end
241+
242+
local delta = full:sub(committed + 1, target_pos)
243+
committed = target_pos
244+
245+
-- Append the new characters to the line mirror, opening new lines on newlines.
246+
local parts = vim.split(delta, "\n", { plain = true })
247+
buf_lines[#buf_lines] = buf_lines[#buf_lines] .. parts[1]
248+
for k = 2, #parts do
249+
buf_lines[#buf_lines + 1] = parts[k]
250+
end
251+
252+
-- Push only the changed tail to the buffer (the previous last line plus any
253+
-- newly opened lines). This preserves extmarks on earlier lines.
254+
local tail_from = #buf_lines - #parts + 1
255+
vim.api.nvim_buf_set_lines(buf, tail_from - 1, -1, false, vim.list_slice(buf_lines, tail_from))
256+
257+
-- Mark only lines that are now fully revealed and not yet marked:
258+
-- a line is complete once a newline has closed it, or (at the end) the final
259+
-- line regardless of termination. Cap at #line_types so a trailing empty
260+
-- element (content ending in "\n") is never marked.
261+
local complete_count
262+
if committed >= len then
263+
complete_count = (#buf_lines > 0 and buf_lines[#buf_lines] == "") and (#buf_lines - 1) or #buf_lines
264+
else
265+
complete_count = math.max(0, #buf_lines - 1)
266+
end
267+
complete_count = math.min(complete_count, #line_types)
268+
269+
for i = marked_count + 1, complete_count do
270+
apply_line_mark(buf, i, line_types[i])
271+
end
272+
marked_count = complete_count
273+
end
274+
275+
local function tick()
276+
if my_gen ~= reveal_gen then
277+
return
278+
end
279+
if not vim.api.nvim_buf_is_valid(buf) then
280+
return
281+
end
282+
if committed >= len then
283+
vim.api.nvim_buf_set_option(buf, "modifiable", false)
284+
return
285+
end
286+
287+
reveal_up_to(committed + chars_per_tick)
288+
289+
if committed >= len then
290+
vim.api.nvim_buf_set_option(buf, "modifiable", false)
291+
return
292+
end
293+
294+
-- Natural typing cadence: randomized 400-1000 WPM, with delay_ms as a floor.
295+
local wpm = math.random(400, 1000)
296+
local delay = math.max(delay_floor_ms, math.floor(wpm_to_ms_per_char(wpm)))
297+
vim.defer_fn(tick, delay)
298+
end
299+
300+
tick()
301+
end
302+
190303
--- Create a scratch editor window when the current tab only has terminal/sidebar windows.
191304
---@return number win_id Window ID of the created scratch editor window
192305
local function create_fallback_editor_window()
@@ -370,9 +483,18 @@ function M.setup_inline_diff(params, resolution_callback, config)
370483
diff._cleanup_diff_state(name, "replaced by new diff")
371484
end
372485

373-
-- Render content + highlights (static full render).
374-
M.render_diff_buffer(buf, lines, line_types)
375-
vim.api.nvim_buf_set_option(buf, "modifiable", false)
486+
-- Render content + highlights. Optionally reveal it with a live "typing" effect.
487+
local do_stream = config and config.diff_opts and config.diff_opts.stream_reveal
488+
if do_stream then
489+
vim.api.nvim_buf_set_option(buf, "modifiable", true)
490+
render_diff_buffer_streamed(buf, lines, line_types, {
491+
delay_ms = config.diff_opts.stream_reveal_delay_ms or 0,
492+
chars_per_tick = config.diff_opts.stream_reveal_chars_per_tick or 1,
493+
})
494+
else
495+
M.render_diff_buffer(buf, lines, line_types)
496+
vim.api.nvim_buf_set_option(buf, "modifiable", false)
497+
end
376498
partial_setup.new_buffer = buf
377499

378500
-- Buffer metadata
@@ -613,6 +735,9 @@ end
613735
function M.cleanup_inline_diff(tab_name, diff_data)
614736
local diff = require("claudecode.diff")
615737

738+
-- Cancel any in-flight streaming reveal so it stops mutating the buffer.
739+
reveal_gen = reveal_gen + 1
740+
616741
-- Clean up autocmds
617742
for _, autocmd_id in ipairs(diff_data.autocmd_ids or {}) do
618743
pcall(vim.api.nvim_del_autocmd, autocmd_id)

0 commit comments

Comments
 (0)