@@ -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.
1313local 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
188194end
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
192305local 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
613735function 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