Skip to content

[FEATURE] Inline diff #82

Description

@jsulopzs

Is it possible to use an inline diff version?

Activity

  1. benbrastmckie commented on Sep 16, 2025

    @benbrastmckie

    Until this feature has been added, is there a way to prevent the split diffs from appearing and only see the suggested changes in the claude code sidebar?

  2. jakubbortlik commented on Oct 13, 2025

    @jakubbortlik

    You can try creating a custom output style with something like this: /output-style:new Claude only shows diffs for suggested changes in the Claude Code pane, but doesn't create diffs in the linked IDE. With this command, Claude created for me a custom style in ~/.claude/output-styles/diff-review-only.md and when I activated this output style with /output-style, Claude really reacted by just showing the diff in the Claude Code pane (at least for a simple refactor task). When I re-enabled the default output style, Claude started creating diffs in Nvim again.

  3. ThomasK33 commented on Oct 15, 2025

    @ThomasK33
    Member

    Until this feature has been added, is there a way to prevent the split diffs from appearing and only see the suggested changes in the claude code sidebar?

    There's a setting in Claude Code for this. Once connected to the Neovim plugin, a "Diff Tool" option will appear under /config. You can then select whether diffs are displayed in Neovim (auto) or within Claude Code (terminal).


    Diffs will be shown in Neovim:

    Image

    Diffs will be shown in Claude Code:

    Image
  4. ThomasK33 commented on Jun 22, 2026

    @ThomasK33
    Member

    Note

    This triage report is AI-generated using Claude Code

    Triage: inline diff

    This issue is a feature request. Reading through the thread, two distinct asks are present, and separating them helps:

    • (A) An inline / unified diff inside Neovim — a single, compact buffer with additions and deletions interleaved, instead of the current side-by-side split. This is the original request.
    • (B) Suppressing the Neovim diff entirely — raised later in the thread — so proposed changes are shown only inside the Claude Code pane. This one is already solved: selecting /config → Diff Tool → terminal makes Claude render diffs in its own TUI and never call the editor's openDiff at all (as covered earlier in this thread). The custom output-style suggestion mentioned above also works; the built-in setting is the durable answer.

    The remainder of this report concerns ask (A).

    Is an inline diff feasible and in scope?

    Yes on both counts.

    • The protocol does not mandate a layout. The openDiff MCP tool is defined by four parameters and two text results (FILE_SAVED + final contents on accept, DIFF_REJECTED + tab name on reject) — there is no orientation / side-by-side field anywhere (PROTOCOL.md §2; lua/claudecode/tools/open_diff.lua). Diff presentation is therefore entirely client-side. This is corroborated by an independent client of the identical protocol (manzaltu/claude-code-ide.el), which renders its own way while honoring the same contract.
    • Relationship to the official VS Code experience. The official extension's default is actually a side-by-side, editable comparison; its inline / unified view is a VS Code editor toggle (diffEditor.renderSideBySide), not a Claude setting. The current side-by-side layout here is therefore faithful to upstream, and an inline view would be an opt-in enhancement rather than a parity fix.
    • It can be done with zero dependencies. A single-buffer unified diff is achievable with built-ins only — vim.diff() for hunk computation plus extmarks for rendering — on Neovim ≥ 0.9. (Verified against api.txt at the v0.9.0 tag: virt_lines, line_hl_group, sign_text, and sign_hl_group are all present then; vim.diff itself dates to 0.6 and was renamed vim.text.diff in 0.12, with the old name kept as an alias.)

    An implementation already exists: PR #195

    PR #195 already implements this. It adds a self-contained diff_inline.lua (pure vim.diff() + extmark rendering with + / - sign markers), dispatches accept / reject / cleanup on layout == "inline", and ships 23 tests.

    The approach was reviewed and exercised during this triage:

    • Correctness — confirmed. The diff computation and accept-time content extraction reconstruct exactly the proposed file across modify / new-file / pure-deletion cases (the read-only buffer keeps deleted lines for display, and accept strips them via the saved line-type array). A standalone run is in the first collapsible box below.
    • Version gate — correct. The stated requirement of Neovim ≥ 0.9 is accurate, and even slightly conservative.
    • Points worth addressing before merge:
      • The branch is currently out of date and conflicts with main (its base predates roughly 35 commits of diff.lua refactoring — including the reject-on-:q, only-window, new-tab, foreign-diff, and terminal-resize changes), so a rebase is required.
      • vim.diff() is called directly; binding vim.text.diff or vim.diff keeps it forward-compatible with 0.12+.
      • The buffer is rendered read-only, which forfeits the ability to edit a proposal before accepting — see the next section.

    Design note: editability

    The current side-by-side layout (and the official VS Code experience) allows a proposal to be edited before accepting, with the final buffer contents returned in FILE_SAVED. The inline buffer in PR #195 is read-only, so that capability is lost in inline mode. The same trade-off appears in a sibling fork that also moved to an extmark overlay (douglasjordan2/claudecode.nvim).

    This trade-off is avoidable. If the proposed lines are rendered as real, editable buffer lines (highlighted, with a + sign) and the deleted lines are shown as read-only virtual lines (virt_lines), the inline view stays compact and editable, and accept simply returns the live buffer contents. A prototype of this pattern is in the second collapsible box; a simulated edit to a proposed line is correctly captured on accept while the virtual deletions are excluded. This mirrors how codecompanion.nvim's built-in inline engine and folke/sidekick.nvim render.

    On delegating to an external diff engine (relates to #169)

    Delegating the diff view to diffview.nvim or codediff.nvim was also evaluated, since #169 requests it. It is not recommended: diffview.nvim requires a Git root and a heavier (GPL-3) dependency and still leaves the blocking accept / reject + final-content handshake to be built here; codediff.nvim cannot render an in-memory right-hand side at all. Notably, codecompanion.nvim removed its pluggable diff providers (including mini.diff) in favor of a single built-in engine — a useful precedent. A self-contained, pure-Lua engine is the better fit for this project's zero-dependency design. (A bonus of a single-buffer extmark view: it opens no &diff windows, so it sidesteps the class of foreign-diff-window problems seen in #277 and the stale-diff accumulation in #205.)

    Workarounds available today

    • For ask (B): /config → Diff Tool → terminal (built-in), or a custom output style.
    • For ask (A): there is no configuration-only workaround — the native diffopt inline options only affect two-buffer diff mode, not a single-buffer unified view — so this needs code (i.e. PR feat(diff): add layout = "inline" for VS Code-style unified diff #195 or equivalent). The closest existing options are diff_opts.layout = "horizontal" (less width) or the terminal Diff Tool above.

    Minor documentation / cleanup notes

    • The README documents only layout = "vertical" | "horizontal"; an inline option would need a docs entry.
    • diff_opts.auto_close_on_accept and diff_opts.show_diff_stats are validated in config.lua but not implemented or documented anywhere (they appear to date back to an early PR) — candidates for either implementation or removal.

    Suggested direction

    Adopting PR #195 as the basis — after a rebase, the forward-compatible diff call, and a decision on the editability model (the editable proposed-lines pattern is recommended) — looks like the most sensible path, and #169 can be addressed by the same self-contained engine rather than an external dependency.

    The two prototypes used to ground this report are included below for review.

    proto_inline.lua — verification of PR #195's diff algorithm (run under headless Neovim 0.12.3)
    -- Standalone prototype of PR #195's inline-diff core (pure functions copied verbatim),
    -- exercised in real headless nvim to verify correctness + probe version gates.
    
    local function split_lines(text)
      if text == "" then return {} end
      local lines = vim.split(text, "\n", { plain = true })
      if #lines > 0 and lines[#lines] == "" then table.remove(lines) end
      return lines
    end
    
    local function compute_inline_diff(old_text, new_text)
      old_text = old_text or ""
      new_text = new_text or ""
      local hunks = vim.diff(old_text, new_text, { result_type = "indices" }) or {}
      local old_lines = split_lines(old_text)
      local new_lines = split_lines(new_text)
      local result_lines, result_types = {}, {}
      local old_pos, new_pos = 1, 1
      for _, hunk in ipairs(hunks) do
        local start_a, count_a, _, count_b = hunk[1], hunk[2], hunk[3], hunk[4]
        local unchanged_count
        if count_a > 0 then unchanged_count = start_a - old_pos
        else unchanged_count = start_a - old_pos + 1 end
        for _ = 1, unchanged_count do
          result_lines[#result_lines + 1] = new_lines[new_pos]
          result_types[#result_types + 1] = "unchanged"
          old_pos = old_pos + 1; new_pos = new_pos + 1
        end
        for _ = 1, count_a do
          result_lines[#result_lines + 1] = old_lines[old_pos]
          result_types[#result_types + 1] = "deleted"
          old_pos = old_pos + 1
        end
        for _ = 1, count_b do
          result_lines[#result_lines + 1] = new_lines[new_pos]
          result_types[#result_types + 1] = "added"
          new_pos = new_pos + 1
        end
      end
      while new_pos <= #new_lines do
        result_lines[#result_lines + 1] = new_lines[new_pos]
        result_types[#result_types + 1] = "unchanged"
        new_pos = new_pos + 1
      end
      return result_lines, result_types
    end
    
    local function extract_new_content(lines, line_types)
      local out = {}
      for i, lt in ipairs(line_types) do
        if lt ~= "deleted" then out[#out + 1] = lines[i] end
      end
      return table.concat(out, "\n")
    end
    
    local function P(s) io.write(s .. "\n") end
    
    P("=== nvim version ===")
    P(tostring(vim.version()))
    P("vim.diff present: " .. tostring(vim.diff ~= nil))
    
    -- Probe: does nvim_buf_set_extmark accept sign_text in THIS version?
    local probe_buf = vim.api.nvim_create_buf(false, true)
    vim.api.nvim_buf_set_lines(probe_buf, 0, -1, false, { "x" })
    local ns = vim.api.nvim_create_namespace("proto")
    local ok_sign = pcall(vim.api.nvim_buf_set_extmark, probe_buf, ns, 0, 0, {
      sign_text = "+", sign_hl_group = "DiffAdd", line_hl_group = "DiffAdd",
    })
    P("nvim_buf_set_extmark{sign_text=...} accepted: " .. tostring(ok_sign))
    
    local function show_case(title, old_text, new_text)
      P("\n=== CASE: " .. title .. " ===")
      local lines, types = compute_inline_diff(old_text, new_text)
      for i = 1, #lines do
        local mark = ({ unchanged = "  ", added = "+ ", deleted = "- " })[types[i]]
        P(string.format("%s%s", mark, lines[i]))
      end
      local extracted = extract_new_content(lines, types)
      -- The accepted content must equal new_text (modulo trailing newline handling)
      local norm = function(s) return (s:gsub("\n$", "")) end
      local match = norm(extracted) == norm(new_text)
      P(string.format("-> extract_new_content == new_text ? %s", tostring(match)))
      if not match then
        P("   !! MISMATCH")
        P("   extracted=[" .. extracted .. "]")
        P("   expected =[" .. norm(new_text) .. "]")
      end
    end
    
    local old1 = table.concat({
      "local function greet(name)",
      "  print('hi ' .. name)",
      "  return true",
      "end",
    }, "\n")
    local new1 = table.concat({
      "local function greet(name)",
      "  -- be polite",
      "  print('hello, ' .. name .. '!')",
      "  return true",
      "end",
    }, "\n")
    show_case("modify a function", old1, new1)
    
    show_case("new file (old empty)", "", "line one\nline two\nline three")
    
    show_case("pure deletion", "keep\ndrop me\nkeep2", "keep\nkeep2")
    
    -- Demonstrate the read-only limitation: in inline mode the buffer holds deleted
    -- lines too, so accept must reconstruct from line_types (NOT from buffer text).
    -- If a user could edit the buffer, their edits to 'deleted' lines would be discarded,
    -- and edits to 'added' lines are only preserved if accept re-reads the buffer (it does NOT).
    P("\n=== editability note ===")
    P("PR #195 sets modifiable=false and reconstructs accepted content from the saved")
    P("line_types array, so user edits to the proposal are impossible (unlike vertical/horizontal,")
    P("where the proposed buffer is modifiable and accept re-reads live buffer lines).")
    
    vim.cmd("qa!")

    Output (headless nvim --headless -l):

    === nvim version ===
    0.12.3+v0.12.3
    vim.diff present: true
    nvim_buf_set_extmark{sign_text=...} accepted: true
    
    === CASE: modify a function ===
      local function greet(name)
    -   print('hi ' .. name)
    +   -- be polite
    +   print('hello, ' .. name .. '!')
        return true
      end
    -> extract_new_content == new_text ? true
    
    === CASE: new file (old empty) ===
    + line one
    + line two
    + line three
    -> extract_new_content == new_text ? true
    
    === CASE: pure deletion ===
      keep
    - drop me
      keep2
    -> extract_new_content == new_text ? true
    
    === editability note ===
    PR #195 sets modifiable=false and reconstructs accepted content from the saved
    line_types array, so user edits to the proposal are impossible (unlike vertical/horizontal,
    where the proposed buffer is modifiable and accept re-reads live buffer lines).
    
    proto_editable_inline.lua — prototype of the editability-preserving inline pattern (proposed lines real & editable, deletions as virtual lines)
    -- Prototype: editability-PRESERVING inline unified diff (the pattern PR #195 does NOT use).
    -- Buffer holds the NEW/proposed content as REAL, EDITABLE lines (added lines highlighted +
    -- "+" sign). Deleted OLD lines are shown as read-only virtual lines (virt_lines) anchored in
    -- place. On accept, final content = the live buffer text (so user edits are captured), exactly
    -- like the current vertical/horizontal split and like VS Code.
    
    local diff_fn = vim.text and vim.text.diff or vim.diff -- forward-compatible (0.12 renamed it)
    
    local function split_lines(text)
      if text == "" then return {} end
      local l = vim.split(text, "\n", { plain = true })
      if #l > 0 and l[#l] == "" then table.remove(l) end
      return l
    end
    
    local ns = vim.api.nvim_create_namespace("proto_editable")
    vim.api.nvim_set_hl(0, "PInlAdd", { link = "DiffAdd", default = true })
    vim.api.nvim_set_hl(0, "PInlDel", { link = "DiffDelete", default = true })
    
    --- Render new_text as real editable lines; show deletions as virt_lines. Returns buf.
    local function render(old_text, new_text)
      local buf = vim.api.nvim_create_buf(false, true)
      local new_lines = split_lines(new_text)
      local old_lines = split_lines(old_text)
      vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines) -- REAL proposed content
      vim.bo[buf].modifiable = true                            -- editable!
    
      local hunks = diff_fn(old_text, new_text, { result_type = "indices" }) or {}
      local added, deleted = 0, 0
      for _, h in ipairs(hunks) do
        local start_a, count_a, start_b, count_b = h[1], h[2], h[3], h[4]
    
        -- Added new lines -> real lines, highlight + "+" sign
        for r = start_b, start_b + count_b - 1 do
          added = added + 1
          vim.api.nvim_buf_set_extmark(buf, ns, r - 1, 0, {
            line_hl_group = "PInlAdd", sign_text = "+", sign_hl_group = "PInlAdd",
          })
        end
    
        -- Deleted old lines -> read-only virtual lines, anchored at the hunk
        local vlines = {}
        for r = start_a, start_a + count_a - 1 do
          deleted = deleted + 1
          vlines[#vlines + 1] = { { "- " .. (old_lines[r] or ""), "PInlDel" } }
        end
        if #vlines > 0 then
          if count_b > 0 then
            -- anchor above the first added line
            vim.api.nvim_buf_set_extmark(buf, ns, start_b - 1, 0,
              { virt_lines = vlines, virt_lines_above = true })
          else
            -- pure deletion: anchor below the preceding kept line (start_b is the kept line)
            local anchor = math.max(start_b - 1, 0)
            vim.api.nvim_buf_set_extmark(buf, ns, anchor, 0,
              { virt_lines = vlines, virt_lines_above = false })
          end
        end
      end
      return buf, added, deleted
    end
    
    local function P(s) io.write(s .. "\n") end
    local function dump(buf)
      local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
      local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })
      -- index extmarks by row
      local add_at, del_at = {}, {}
      for _, m in ipairs(marks) do
        local row, det = m[2], m[4]
        if det.line_hl_group == "PInlAdd" then add_at[row] = true end
        if det.virt_lines then del_at[row] = det.virt_lines end
      end
      for i = 0, #lines - 1 do
        -- print any deletion virt_lines anchored ABOVE this row
        if del_at[i] then for _, vl in ipairs(del_at[i]) do P("  ¦ " .. vl[1][1]) end end
        P((add_at[i] and "+ " or "  ") .. lines[i + 1])
      end
    end
    
    local old1 = table.concat({
      "function greet(name)",
      "  print('hi ' .. name)",
      "  return true",
      "end",
    }, "\n")
    local new1 = table.concat({
      "function greet(name)",
      "  -- be polite",
      "  print('hello, ' .. name .. '!')",
      "  return true",
      "end",
    }, "\n")
    
    P("=== editable inline render (proposed=real, deleted=virtual) ===")
    P("nvim " .. tostring(vim.version()) .. "  diff_fn=" .. (vim.text and vim.text.diff and "vim.text.diff" or "vim.diff"))
    local buf, added, deleted = render(old1, new1)
    dump(buf)
    P(string.format("-> %d added (real,editable), %d deleted (virtual,read-only)", added, deleted))
    
    -- Editability proof: user tweaks an added line, then accepts.
    local cur = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
    for i, l in ipairs(cur) do
      if l:find("hello, ", 1, true) then cur[i] = "  print('Hello there, ' .. name)" end
    end
    vim.api.nvim_buf_set_lines(buf, 0, -1, false, cur) -- simulate a user edit in the diff buffer
    local accepted = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n")
    P("\n=== accept after user edit -> FILE_SAVED content is the LIVE buffer (edits captured) ===")
    P(accepted)
    P("contains user's edited text? " .. tostring(accepted:find("Hello there", 1, true) ~= nil))
    P("virtual deleted lines are NOT in accepted content? " ..
      tostring(accepted:find("print%('hi '") == nil))
    
    vim.cmd("qa!")

    Output (headless nvim --headless -l):

    === editable inline render (proposed=real, deleted=virtual) ===
    nvim 0.12.3+v0.12.3  diff_fn=vim.text.diff
      function greet(name)
      ¦ -   print('hi ' .. name)
    +   -- be polite
    +   print('hello, ' .. name .. '!')
        return true
      end
    -> 2 added (real,editable), 1 deleted (virtual,read-only)
    
    === accept after user edit -> FILE_SAVED content is the LIVE buffer (edits captured) ===
    function greet(name)
      -- be polite
      print('Hello there, ' .. name)
      return true
    end
    contains user's edited text? true
    virtual deleted lines are NOT in accepted content? true
    
  5. added a commit that references this issue on Jun 23, 2026
    f64c307
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions