Skip to content

feat(diff): add layout = "inline" for VS Code-style unified diff - #195

Merged
ThomasK33 merged 3 commits into
coder:mainfrom
zhao-tong:main
Jun 22, 2026
Merged

ThomasK33 merged 3 commits into
coder:mainfrom
zhao-tong:main

Conversation

@zhao-tong

@zhao-tong zhao-tong commented Feb 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add a new layout = "inline" option for diff_opts that renders a VS Code-style unified inline diff - a single read-only buffer with deleted (red/strikethrough) and added (green) lines interleaved. This complements the existing "vertical" and "horizontal" two-panel diff layouts.

Closes #82

Motivation

The existing diff layouts use Neovim's built-in diffthis to show old and new content side by side. While effective, this uses significant screen real estate with two panels. An inline unified diff provides a more compact view that's familiar to VS
Code users, showing changes in context within a single buffer.

Changes

  • New module lua/claudecode/diff_inline.lua — self-contained inline diff implementation with:
    • Pure diff computation via vim.diff() (indices mode)
    • Extmark-based rendering with sign column markers (+/-)
    • Resolution (accept/reject) and cleanup functions
  • Dispatch in diff.lua — routes to inline module when layout = "inline" is configured; exposes shared utilities as M._ members following existing patterns
  • Config/types — validates "inline" as a valid layout value
  • close_all_diff_tabs — detects inline diff buffers via claudecode_inline_diff buffer variable
  • Test mock — adds vim.diff mock with LCS-based hunk computation to tests/mocks/vim.lua
  • 23 new tests covering diff computation, content extraction, rendering, MCP response format, config validation, and cleanup

Configuration

require("claudecode").setup({
  diff_opts = {
    layout = "inline", -- "vertical" (default), "horizontal", or "inline"
  },
})

Highlight groups are customizable:

  • ClaudeCodeInlineDiffAdd — green background for added lines
  • ClaudeCodeInlineDiffDelete — red background + strikethrough for deleted lines
  • ClaudeCodeInlineDiffAddSign / ClaudeCodeInlineDiffDeleteSign — sign column colors

Requirements

  • layout = "inline" requires Neovim >= 0.9.0 (for vim.diff())
  • The plugin's base requirement remains Neovim >= 0.8.0; only inline layout needs 0.9+

Design Decisions

  1. Separate module rather than inlining into diff.lua — keeps the already-large file manageable; inline diff uses fundamentally different rendering (extmarks vs diffthis)
  2. layout field in diff state — enables dispatch in resolution/cleanup without re-reading config
  3. Read-only buffer — inline diff is review-only; content extraction uses saved line_types array rather than parsing buffer text
  4. Always vsplit — single buffer doesn't need horizontal/vertical choice

Test Plan

  • luacheck lua/ tests/ passes with 0 warnings / 0 errors
  • All 406 tests pass (23 new, 5 pre-existing failures unrelated to this change)
  • Manual: layout = "inline" → single-window diff with colored lines
  • Manual: Accept (aa) → file saved correctly, diff closes
  • Manual: Deny (ad) → file unchanged, diff closes
  • Manual: New file creation → all lines shown as green/added
  • Manual: layout = "vertical" still works unchanged (regression check)

@CortlandMorse

Copy link
Copy Markdown

Bumping ➕ , this is a feature I'd love as well.

@joshualawson

Copy link
Copy Markdown

+1 need this!

@RitvarsZvejnieksApplyIT

Copy link
Copy Markdown

+1

@ThomasK33 ThomasK33 mentioned this pull request Jun 22, 2026
zhao-tong and others added 3 commits June 22, 2026 17:22
Add a new inline diff layout that shows deleted (red/strikethrough) and
added (green) lines interleaved in a single read-only buffer, similar to
VS Code's inline diff view. This complements the existing "vertical" and
"horizontal" two-panel diff layouts.

Key changes:
- New `diff_inline.lua` module with pure diff computation, rendering via
  extmarks, and resolution/cleanup functions
- Dispatch logic in `diff.lua` routes to inline module when configured
- Config/types updated to accept `layout = "inline"`
- `close_all_diff_tabs` detects inline diff buffers
- Customizable highlight groups: ClaudeCodeInlineDiff{Add,Delete,*Sign}
- Requires Neovim >= 0.9.0 (for vim.diff); base plugin remains 0.8.0+
- 23 new tests covering computation, rendering, MCP responses, cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Thomas Kosiewski <tk@coder.com>
- closeAllDiffTabs: call _resolve_diff_as_rejected before cleanup so
  pending coroutines are properly resumed instead of hanging on yield
- closeAllDiffTabs: include resolved diffs in CLOSED_N_DIFF_TABS count
- diff_inline: in open_in_new_tab mode, pick editor window from current
  tab via nvim_tabpage_list_wins instead of global search which could
  return a window from the original tab
- Add tests for new-tab window selection and cleanup
- Add nvim_tabpage_list_wins to vim mock

Issues found by Codex 5.3 code review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Two small refinements to the inline diff layout while rebasing onto main:

- Resolve the diff function at call time as `vim.text.diff or vim.diff`.
  Neovim 0.12 renamed `vim.diff` to `vim.text.diff` (old name kept as a
  deprecated alias), so prefer the new name and have the version guard
  check the function actually used. Keeps `layout = "inline"` working
  across the supported range and forward-compatible.

- Store `client_id` in the inline diff state so `close_diffs_for_client`
  tears the diff down if its MCP client disconnects, matching the native
  path (coder#261). Without it, orphaned inline diffs would linger.

Change-Id: I59e3d29729e9b7d7838da25c53c23dc335f784c5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33
ThomasK33 added this pull request to the merge queue Jun 22, 2026
Merged via the queue into coder:main with commit f64c307 Jun 22, 2026
4 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 22, 2026
@wookayin

wookayin commented Jun 22, 2026 •

Copy link
Copy Markdown

Nice feature! However it still opens a new ...(inline diff). VS Code like inline unified diff would mean showing inline diff in virtual text using extmarks, in the same window rather than vsplit.

Also let's beware of potential breaking changes in the layout options, as this sounds more like "inline_vertical" (or "vertical_inline") whereas the true overlay+extmarks mode (once we implement that) should deserve "inline".

@ThomasK33

Copy link
Copy Markdown
Member

Thanks @wookayin — you're right on both counts. The shipped layout = "inline" is a unified diff in a separate rightbelow vsplit, not an in-place virtual-text overlay, and shipping it under the name "inline" would have boxed us into a breaking rename once the real overlay lands.

Since it hasn't gone out in a tagged release yet, we're renaming it now: layout = "inline" → "unified" in #295, which reserves "inline" for the true in-place overlay. The naming rationale is in #293.

I opened #294 to track that real overlay — the prototype direction is proposed lines as real, editable text + deleted lines as virt_lines extmarks, so the proposal stays editable before accept (the codecompanion/sidekick model). You said "once we implement that" — would genuinely welcome your input or a hand on it if you're up for it. 🙏

gogongxt pushed a commit to gogongxt/claudecode.nvim that referenced this pull request Jun 24, 2026
)

## Summary

Renames the `diff_opts.layout = "inline"` option (added in coder#195, queued
for v0.4.0 via release-please coder#279) to **`"unified"`**, before it ships
in a tagged release. Tracked in coder#293.

[@wookayin pointed
out](coder#195 (comment))
that the shipped `"inline"` mode isn't a true "inline" diff in the VS
Code sense — it renders a unified diff in a separate `rightbelow vsplit`
pane, not an in-place virtual-text overlay in the same window. Naming it
`"inline"` now would force a breaking rename later, once we add the real
in-place overlay (the mode that most deserves the name `"inline"`).
Renaming is free now (not yet released) and a breaking change after coder#279
merges.

## What changed

- **Config value** `"inline"` → `"unified"`: validation + error message
(`config.lua`), the `ClaudeCodeDiffLayout` type alias (`types.lua`), all
four dispatch sites (`diff.lua`), and the stored `layout` value
(`diff_inline.lua`).
- **User-facing buffer name**: `<file> (inline diff)` → `<file> (unified
diff)`.
- **Docs**: README layout enum + label; a `CHANGELOG.md` `[Unreleased]`
entry for `layout = "unified"`.
- **Tests**: the accept test now expects `"unified"`; a new test asserts
the former `"inline"` is rejected.

## Decisions

- **No deprecated alias.** `"inline"` was never in a tagged release, so
there's no backward-compat obligation, and rejecting it cleanly reserves
the name for the future in-place overlay (coder#294). The validation error
names `'unified'`, so anyone tracking `main` is pointed at the new
value.
- **Internal names kept.** The `diff_inline.lua` module, its functions,
the `claudecode_inline_diff` buffer variable, and the
`ClaudeCodeInlineDiff*` highlight groups are unchanged — they describe
the inline-*style* rendering, not the layout value (coder#293 noted the
buffer var can stay). This keeps the diff small and low-risk.
- **coder#279 changelog** regenerates from commits via Communique on the next
push to `main`, so the v0.4.0 entry reflects "unified" automatically; I
did not hand-edit the bot branch.

## Validation

- `mise run all`: treefmt clean, luacheck **0/0**, **703/703** tests
pass (baseline 702 + the new rejection test).
- Headless Neovim smoke test: `layout = "unified"` accepted, `"inline"`
rejected (error names `'unified'`), `"vertical"` still works.

The true in-place overlay (the future `"inline"`) is tracked in coder#294.

Refs coder#293, coder#195, coder#82

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Thomas Kosiewski <tk@coder.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 10, 2026
The next claudecode.nvim release. Merging this PR creates the release
tag and GitHub Release, then starts Communique GitHub Release note
generation.
---


## [0.4.0] - 2026-11-09

### Added

- `:ClaudeCodeSendText {text}` command and
`require("claudecode.terminal").send_to_terminal(text, opts)` to send
arbitrary text to the open Claude terminal as if typed at the prompt
(submitting by default; `:ClaudeCodeSendText!` inserts without
submitting). Multi-line text is sent via bracketed paste. Works with the
in-editor `native`/`snacks` providers only.
([coder#197](coder#197),
[coder#272](coder#272))
- `:checkhealth claudecode` health check that verifies Neovim version,
`setup()`, the Claude CLI, terminal provider, WebSocket server, lock
file, and live connection state.
([coder#275](coder#275))
- `:ClaudeCodeCloseAllDiffs` command to close pending Claude diffs at
once. A diff accepted with `:w` closes once the file holds the accepted
text; otherwise the accepted text is put into the buffer as unsaved
changes for `:w`/`:e!` so edits are never discarded.
([coder#248](coder#248),
[coder#261](coder#261))
- `diff_opts.layout = "unified"`: a VS Code-style unified diff rendered
in a single buffer with interleaved deleted/added lines, as a compact
alternative to `"vertical"`/`"horizontal"`. Documented to require Neovim
>= 0.9.0. Customizable via the
`ClaudeCodeInlineDiffAdd`/`Delete`/`AddSign`/`DeleteSign` highlight
groups. ([coder#195](coder#195),
[coder#295](coder#295),
[coder#297](coder#297))
- `User ClaudeCodeSendComplete` autocmd, fired once per file when a send
is accepted while Claude is connected, with `data = { file_path,
start_line, end_line, context }` (lines 0-indexed). Enables post-send
logic such as focusing a Claude session running outside Neovim.
([coder#228](coder#228),
[coder#265](coder#265))
- `User ClaudeCodeDiffOpened` / `ClaudeCodeDiffClosed` autocmds carrying
diff metadata, plus `diff_opts.auto_resize_terminal` (default `true`) to
opt out of plugin-managed terminal width during diffs and
`terminal.diff_split_width_percentage` to set a distinct terminal width
while a diff is open.
([coder#270](coder#270))
- `terminal.auto_insert` option (default `true`): set to `false` to stay
in Normal mode when refocusing the Claude terminal and preserve your
scroll position instead of jumping to the prompt.
([coder#233](coder#233))
- `ClaudeCodeTreeAdd` / `<leader>as` support in snacks.nvim pickers
(`snacks_picker_list`), covering `Snacks.explorer()` and modal
`Snacks.picker.files()`/`grep()`.
([coder#192](coder#192),
[coder#269](coder#269))
- netrw file selection for `<leader>as` / `:ClaudeCodeTreeAdd`,
supporting marked files and the file under the cursor.
([coder#62](coder#62))

### Changed

- Model picker labels are now version-free evergreen aliases (`Claude
Opus (Latest)`, `Claude Sonnet (Latest)`, `Claude Haiku (Latest)`), with
new 1M-context variants (`opus[1m]`, `sonnet[1m]`) and a `Default
(account recommended)` entry.
([coder#146](coder#146),
[coder#256](coder#256))
- `:checkhealth claudecode` and the MCP `serverInfo.version` now report
the real plugin version from the `VERSION` file instead of a hard-coded
value. ([coder#437](coder#437))

### Fixed

- The Snacks provider no longer fails to launch Claude when a model
alias contains shell-glob characters (e.g. `opus[1m]`); the command is
now passed as an argv list with no shell interpretation.
([coder#256](coder#256))
- The native terminal now adopts only terminal buffers it started
(marked with `b:claudecode_native_terminal`), instead of any visible
terminal whose name contains `claude`. Upgrade note: a terminal that an
older plugin version started (for example after `:Lazy reload` following
an upgrade), or that a session file restored, carries no mark and is no
longer adopted. The next open starts a second Claude session, and the
old terminal keeps running until you close its buffer or quit Claude in
it. ([coder#349](coder#349),
[coder#352](coder#352),
[coder#364](coder#364))
- `closeAllDiffTabs` is now scoped to claudecode's own tracked diffs and
no longer wipes out an open diffview.nvim, fugitive, or native
`:diffsplit` review; `openFile`/`openDiff` also never reuse a window
that is in diff mode.
([coder#277](coder#277),
[coder#290](coder#290))
- `close_tab` now closes only the diff with that tab name and never
deletes a user buffer.
([coder#373](coder#373))
- Closing the native terminal, for example with `:ClaudeCodeClose`, now
stops the Claude job instead of leaving it running.
([coder#336](coder#336))
- The Claude terminal now adds loopback hosts to `no_proxy`/`NO_PROXY`
so a configured proxy no longer tunnels Claude's `ws://127.0.0.1` IDE
connection and times out queued @ mentions.
([coder#70](coder#70),
[coder#268](coder#268))
- `focus_after_send = true` with `provider = "none"`/`"external"` now
emits a one-time warning pointing to the `User ClaudeCodeSendComplete`
autocmd instead of failing silently.
([coder#228](coder#228),
[coder#265](coder#265))
- Rejecting a Claude diff with `:q` / `:close` / `<C-w>c` (or closing
the tab) now resolves it as rejected via a `WinClosed` autocmd.
([coder#266](coder#266))
- Quickly-made and single-line visual selections are now pushed to
Claude reliably; selections are flushed synchronously on visual-mode
exit and persist until the cursor moves, including in the external
`/ide` flow and Select mode.
([coder#246](coder#246),
[coder#267](coder#267),
[coder#367](coder#367),
[coder#381](coder#381),
[coder#388](coder#388))
- Pending diffs are now automatically closed when the client that opened
them disconnects or the integration stops, so orphaned proposals no
longer linger.
([coder#248](coder#248),
[coder#261](coder#261))
- `openDiff` now works when the Claude terminal is the only window,
creating a split to host the diff instead of failing with "No suitable
editor window found".
([coder#260](coder#260))
- Fixed the "climbing cursor" in the Snacks terminal, where the cursor
sat one row above Claude's prompt after hide and show. On Neovim >= 0.10
a floating terminal is now hidden instead of closed. A split is still
closed on hide and created again on show, which keeps the cursor in
place. Floating terminals on Neovim < 0.10 keep the old behavior.
([coder#240](coder#240),
[coder#183](coder#183),
[coder#271](coder#271))
- Worked around a Neovim core bug (< 0.12.2) that fragmented large
bracketed pastes into the terminal; controlled by
`terminal.fix_streamed_paste` (`"auto"` by default).
([coder#252](coder#252))
- `openFile` now selects exactly the requested line range or matched
text, keeps the user's Visual mode, unsaved changes, focus and preview
window intact, never prompts W11/W12, and never picks the preview window
as the main editor window.
([coder#353](coder#353),
[coder#365](coder#365),
[coder#370](coder#370),
[coder#375](coder#375),
[coder#382](coder#382),
[coder#384](coder#384),
[coder#387](coder#387),
[coder#396](coder#396),
[coder#408](coder#408))
- `getDiagnostics` now handles plain file paths without a `file://` URI
scheme, and IDE diagnostics return grouped URI-based payloads with
editor-native ranges and severity names.
([coder#163](coder#163),
[coder#274](coder#274))
- `:ClaudeCodeAdd` and `openFile` now handle `$` in file paths.
([coder#286](coder#286))
- Prevented a segfault when accepting a new-file diff with
`render-markdown.nvim` installed, by turning off diff mode before the
post-write redraw.
([coder#224](coder#224))
- The auth token is now generated from a CSPRNG and the lock file is
written atomically with mode `0600` in a `0700` directory.
([coder#259](coder#259))
- Detect system sleep/wake and reset client keepalive timestamps to
prevent false disconnections.
([coder#141](coder#141))
- The WebSocket server now retries port binding on `EADDRINUSE`
(parallel instances), closes connections on malformed/oversized or
binary frames with the correct status, joins fragmented messages,
validates UTF-8 per message, and never repeats a client id within a
process. ([coder#258](coder#258),
[coder#283](coder#283),
[coder#378](coder#378),
[coder#383](coder#383),
[coder#390](coder#390),
[coder#395](coder#395),
[coder#405](coder#405),
[coder#422](coder#422),
[coder#431](coder#431))
- Legacy `diff_opts` (e.g. `vertical_split`, `open_in_current_tab`) now
take effect again after the config merge-order regression.
([coder#142](coder#142))
- `:q` on a diff window no longer hangs; floating diff terminals keep
focus; empty buffers are wiped when the terminal provider is `none`; and
accepted diffs are handed over in the buffer's own line-ending format.
([coder#178](coder#178),
[coder#223](coder#223),
[coder#266](coder#266),
[coder#392](coder#392))

### Compatibility

- Integration tests now run on Neovim v0.8.0, and a new-tab diff no
longer fails there because Neovim 0.8 has no `statuscolumn`. The unified
layout is documented to require Neovim >= 0.9.0, and the v0.8.0
integration tests skip it.
([coder#369](coder#369),
[coder#394](coder#394))

---
Maintained by the release-please workflow. CHANGELOG.md and the release
PR body are regenerated by Communique on every push to main, so manual
edits to this PR are overwritten.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kosiewski <tk@coder.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.

[FEATURE] Inline diff

6 participants