Skip to content

feat: streaming 'typing' reveal for the unified inline diff#301

Open
spoloxs wants to merge 6 commits into
coder:mainfrom
spoloxs:feat/inline-diff-streaming
Open

feat: streaming 'typing' reveal for the unified inline diff#301
spoloxs wants to merge 6 commits into
coder:mainfrom
spoloxs:feat/inline-diff-streaming

Conversation

@spoloxs

@spoloxs spoloxs commented Jul 6, 2026

Copy link
Copy Markdown

Summary

Adds an opt-in animated "typing" reveal for the unified inline diff introduced in #300.

  • Incremental & O(n): each tick appends only the newly revealed tail; extmarks added only for newly complete lines.
  • Cancellable: reveal_gen stops animation on new diff or cleanup.
  • Same toggle-anytime behavior: buffer opens/closes via <leader>dt/<leader>dc and stays open after accept/reject.

Enable

require('claudecode').setup({
  diff_opts = {
    stream_reveal = true,
    stream_reveal_chars_per_tick = 3,
  }
})

Related

Stacks on #300.

Introduce a VS Code-style unified inline diff: a single persistent,
read-only buffer (ClaudeCodeBuffer) showing deleted lines in red/
strikethrough and added lines in green, interleaved in one pane.

- Pure-Lua interleave via vim.text.diff/vim.diff (zero external deps, coder#169)
- Single reused buffer (bufhidden=hide) kept open showing the reviewed
  file after accept/reject, so it survives window/tab closes
- Tears down any prior unified diff in the same buffer before rendering,
  preventing stale diffs from accumulating (fixes coder#205)
- Wires ClaudeCodeDiffOpened/ClaudeCodeClosed User autocmds for the
  unified layout (noted in coder#294)
- Tracks originating client_id so close_diffs_for_client can tear the
  diff down on disconnect (parity with the native path, coder#261)
- Dispatch in diff.lua branches on layout == 'unified'

Implements the unified inline diff layout described in coder#294.
Copilot AI review requested due to automatic review settings July 6, 2026 21:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in streamed “typing” reveal for the unified diff renderer, while also refactoring unified diff UI to reuse a persistent buffer/window and improving diff error formatting/cleanup behavior.

Changes:

  • Introduces a cancellable, incremental streamed renderer for unified diffs (stream_reveal* options) and shares highlight logic between static/streamed paths.
  • Reuses a single persistent ClaudeCodeBuffer across unified diffs and changes cleanup to leave the buffer/tab open showing the reviewed file.
  • Updates diff tab/window handling and improves error formatting/logging during diff setup failures.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
lua/claudecode/diff.lua Adds editor-window reuse in a “diff tab”, tweaks new-tab terminal display behavior, improves setup error formatting/logging, and adjusts accept/deny cleanup calls.
lua/claudecode/diff_inline.lua Implements the streamed reveal renderer + cancellation, introduces a persistent unified diff buffer, reworks setup/cleanup flow, and reuses diff windows where possible.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lua/claudecode/diff.lua
Comment on lines 1629 to +1631
if not setup_success then
local error_msg
if type(setup_error) == "table" and setup_error.message then
-- Handle structured error objects
error_msg = "Failed to setup diff operation: " .. setup_error.message
if setup_error.data then
error_msg = error_msg .. " (" .. setup_error.data .. ")"
end
error_msg = "Failed to setup diff operation: " .. format_error(setup_error)

Comment thread lua/claudecode/diff_inline.lua Outdated
Comment on lines +528 to +532
local function reuse_or_open_diff_window()
local diff_win = diff.get_or_create_editor_win_in_diff_tab()
if diff_win then
vim.api.nvim_set_current_win(diff_win)
end
Comment thread lua/claudecode/diff_inline.lua Outdated
Comment on lines +795 to +817
local content_lines = {}
local f = io.open(diff_data.old_file_path, "r")
if f then
local text = f:read("*a") or ""
f:close()
content_lines = vim.split(text, "\n", { plain = true })
if #content_lines > 0 and content_lines[#content_lines] == "" then
table.remove(content_lines, #content_lines)
end
end
vim.api.nvim_buf_set_lines(b, 0, -1, false, content_lines)

-- Keep it as a persistent review buffer (no auto-wipe) showing the file.
vim.api.nvim_buf_set_option(b, "buftype", "nofile")
vim.api.nvim_buf_set_option(b, "bufhidden", "hide")
vim.api.nvim_buf_set_option(b, "modifiable", false)
vim.b[b].claudecode_inline_diff = nil
vim.b[b].claudecode_diff_tab_name = nil

local ft = diff._detect_filetype(diff_data.old_file_path)
if ft and ft ~= "" then
vim.api.nvim_set_option_value("filetype", ft, { buf = b })
end
Comment thread lua/claudecode/diff.lua Outdated
Comment on lines +399 to +403
local function get_or_create_diff_tab()
local tabs = vim.api.nvim_list_tabpages()
if #tabs >= 2 then
return tabs[2]
end
Comment thread lua/claudecode/diff.lua
Comment on lines +1424 to +1428
if original_tab_number and vim.api.nvim_tabpage_is_valid(original_tab_number) then
vim.schedule(function()
vim.api.nvim_set_current_tabpage(original_tab_number)
end)
end
Comment on lines +202 to +216
--- Progressively reveal `lines` in `buf` with a natural "typing" cadence.
---
--- The reveal is *incremental*: each tick advances by `chars_per_tick` characters,
--- appends only the new tail to the buffer (never rewriting earlier lines), and
--- adds extmarks only for lines that just became fully revealed. This keeps the
--- work O(n) in the number of lines instead of O(n²), and because earlier lines
--- and their marks are never touched, they stay correctly positioned.
---@param buf number Buffer handle
---@param lines string[] Full diff lines (source of truth)
---@param line_types string[] Parallel type array
---@param opts table|nil { delay_ms:number, chars_per_tick:number }
local function render_diff_buffer_streamed(buf, lines, line_types, opts)
opts = opts or {}
local chars_per_tick = math.max(1, opts.chars_per_tick or 1)
local delay_floor_ms = math.max(0, opts.delay_ms or 0)
- Hoist format_error to module scope so the open_diff_blocking error
  path can use it (it was defined inside _setup_blocking_diff and raised a
  secondary error, obscuring the original failure).
- Declare error_msg as local (was implicitly a global).
- Stop reusing tabs[2] in get_or_create_diff_tab; always :tabnew so cleanup
  never closes an unrelated user tab (issue flagged by reviewer).
- Rename the internal export get_or_create_editor_win_in_diff_tab ->
  _get_or_create_editor_win_in_diff_tab for API consistency.
- Guard reuse_or_open_diff_window to only reuse a window in the current
  tabpage, avoiding hijacking/switching to an unrelated tab.
@spoloxs spoloxs force-pushed the feat/inline-diff-streaming branch from 05f31fb to e79a0b9 Compare July 6, 2026 22:00
…ffer in current tab

- The unified diff no longer auto-opens a window or creates tab 2.
-  prepares  silently.
- Added  and  for user-initiated
  buffer access (works in any tab, creates a vsplit if needed).
- Cleanup closes any window showing  and keeps the
  buffer alive showing the reviewed file.
- Removed , , tab2 logic from
  this path; those remain for native diff.

This eliminates the tab-3 confusion: the diff always lives in
 and the user decides where to view it.
@spoloxs spoloxs force-pushed the feat/inline-diff-streaming branch from e79a0b9 to d08e7d0 Compare July 6, 2026 22:44
spoloxs and others added 3 commits July 9, 2026 00:44
Addresses review feedback and several lifecycle bugs in the unified inline
diff (ClaudeCodeBuffer):

- Never revert the buffer to the old file on resolve. cleanup now loads
  new_file_contents (the proposed change) so the buffer shows the updated
  file after both accept and reject, instead of stale old content (on
  accept Claude may not have written the file yet). Resolves the Copilot
  comment about reloading old_file_path for new files.
- Do not close the buffer's window on resolve; the persistent buffer stays
  on screen in place (bufhidden="hide").
- Drop the WinClosed reject autocmd: with bufhidden="hide" closing/toggling
  the window must only hide it, never send DIFF_REJECTED. Rejection is
  explicit (ClaudeCodeDiffDeny) or via real buffer deletion (:bd/:bw).
- On reject, resolve every queued diff as DIFF_REJECTED so their blocking
  openDiff MCP calls don't hang forever.
- Reset current_diff_tab on setup failure so a failed setup can't wedge the
  queue behind a dead diff; set the pending marker only after a successful
  render, and guard rendering so failures run cleanup.
- Cancel any in-flight streaming animation on resolve/cleanup so it can't
  clobber the reloaded file view.
- Auto-scroll the diff window to the first changed line (centered) whenever
  the buffer is shown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntent

- Restore auto-opening the ClaudeCodeBuffer in a split in the current tab at
  setup (reusing an open window if present), with a scratch fallback window
  for terminal-only tabs. This re-satisfies the maintainer's unified-layout
  lifecycle tests (ClaudeCodeDiffOpened carries a valid diff_window/
  target_window; terminal-only fallback_window; setup-failure cleanup), which
  the earlier "simplify" refactor had broken by removing window opening.
- On resolve, keep the diff window open (persistent buffer) but close the
  throwaway scratch fallback window.
- cleanup now shows plain content: proposed on accept, ORIGINAL on reject/
  cancel (previously it left the proposed change on screen after a reject).
  Original text is captured at setup as old_file_contents.
- Jump to the first changed line in the diff window.

Tests:
- Update cleanup_inline_diff specs to the persistent-buffer behavior (window
  stays open; accept shows proposed, reject shows original).
- Add nvim_buf_clear_namespace / nvim_buf_del_extmark to the vim mock so the
  cleanup and streaming reanchor paths are exercised under busted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Claude-side accept writes the file and then closes the still-pending diff;
close_diff_by_tab_name treats a pending close as a reject, so status becomes
"rejected" and the buffer showed the original — i.e. the accepted change did
not appear.

cleanup_inline_diff now reconciles from disk for the non-"saved" path: it shows
the original immediately, then (after Claude's async write lands) reloads the
file from disk. Disk is the source of truth — new content if the change was
applied, original if it was genuinely rejected (file untouched). Explicit `:w`
accepts (status "saved") still trust new_file_contents with no disk read, so
they never show stale content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants