Skip to content

Commit f64c307

Browse files
zhao-tongclaudeThomasK33
authored
feat(diff): add layout = "inline" for VS Code-style unified diff (#195)
## 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 ```lua 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 - [x] luacheck lua/ tests/ passes with 0 warnings / 0 errors - [x] All 406 tests pass (23 new, 5 pre-existing failures unrelated to this change) - [x] Manual: layout = "inline" → single-window diff with colored lines - [x] Manual: Accept (<leader>aa) → file saved correctly, diff closes - [x] Manual: Deny (<leader>ad) → file unchanged, diff closes - [x] Manual: New file creation → all lines shown as green/added - [x] Manual: layout = "vertical" still works unchanged (regression check) --------- Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Thomas Kosiewski <tk@coder.com>
1 parent 03f8bbe commit f64c307

7 files changed

Lines changed: 1219 additions & 8 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,11 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
371371

372372
-- Diff Integration
373373
diff_opts = {
374-
layout = "vertical", -- "vertical" or "horizontal"
374+
layout = "vertical", -- "vertical" (default), "horizontal", or "inline"
375+
-- "inline": VS Code-style unified diff in a single buffer with deleted
376+
-- (red/strikethrough) and added (green) lines interleaved. Requires
377+
-- Neovim >= 0.9.0. Highlight groups are customizable: ClaudeCodeInlineDiffAdd,
378+
-- ClaudeCodeInlineDiffDelete, ClaudeCodeInlineDiffAddSign, ClaudeCodeInlineDiffDeleteSign.
375379
open_in_new_tab = false,
376380
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
377381
hide_terminal_in_new_tab = false,

lua/claudecode/config.lua

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,10 @@ function M.validate(config)
130130
-- New diff options (optional validation to allow backward compatibility)
131131
if config.diff_opts.layout ~= nil then
132132
assert(
133-
config.diff_opts.layout == "vertical" or config.diff_opts.layout == "horizontal",
134-
"diff_opts.layout must be 'vertical' or 'horizontal'"
133+
config.diff_opts.layout == "vertical"
134+
or config.diff_opts.layout == "horizontal"
135+
or config.diff_opts.layout == "inline",
136+
"diff_opts.layout must be 'vertical', 'horizontal', or 'inline'"
135137
)
136138
end
137139
if config.diff_opts.open_in_new_tab ~= nil then

lua/claudecode/diff.lua

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,13 @@ function M._resolve_diff_as_saved(tab_name, buffer_id)
826826
return
827827
end
828828

829+
-- Dispatch to inline diff handler
830+
if diff_data.layout == "inline" then
831+
local inline = require("claudecode.diff_inline")
832+
inline.resolve_inline_as_saved(tab_name, diff_data)
833+
return
834+
end
835+
829836
logger.debug("diff", "Accepting diff for", tab_name)
830837

831838
-- Get content from buffer
@@ -913,6 +920,13 @@ function M._resolve_diff_as_rejected(tab_name)
913920
return
914921
end
915922

923+
-- Dispatch to inline diff handler
924+
if diff_data.layout == "inline" then
925+
local inline = require("claudecode.diff_inline")
926+
inline.resolve_inline_as_rejected(tab_name, diff_data)
927+
return
928+
end
929+
916930
-- Create MCP-compliant response
917931
local result = {
918932
content = {
@@ -1135,6 +1149,14 @@ function M._cleanup_diff_state(tab_name, reason)
11351149
return
11361150
end
11371151

1152+
-- Dispatch to inline diff handler
1153+
if diff_data.layout == "inline" then
1154+
local inline = require("claudecode.diff_inline")
1155+
inline.cleanup_inline_diff(tab_name, diff_data)
1156+
active_diffs[tab_name] = nil
1157+
return
1158+
end
1159+
11381160
-- Clean up autocmds
11391161
for _, autocmd_id in ipairs(diff_data.autocmd_ids or {}) do
11401162
pcall(vim.api.nvim_del_autocmd, autocmd_id)
@@ -1276,6 +1298,13 @@ function M._setup_blocking_diff(params, resolution_callback)
12761298
end
12771299
end
12781300

1301+
-- Dispatch to inline diff if configured
1302+
if config and config.diff_opts and config.diff_opts.layout == "inline" then
1303+
local inline = require("claudecode.diff_inline")
1304+
inline.setup_inline_diff(params, resolution_callback, config)
1305+
return
1306+
end
1307+
12791308
local original_tab_number = vim.api.nvim_get_current_tabpage()
12801309
local created_new_tab = false
12811310
local terminal_win_in_new_tab = nil
@@ -1742,6 +1771,18 @@ end
17421771
---This function reads the diff context from buffer variables
17431772
function M.accept_current_diff()
17441773
local current_buffer = vim.api.nvim_get_current_buf()
1774+
1775+
-- Check for inline diff buffer first
1776+
if vim.b[current_buffer].claudecode_inline_diff then
1777+
local tab_name = vim.b[current_buffer].claudecode_diff_tab_name
1778+
if tab_name then
1779+
M._resolve_diff_as_saved(tab_name, current_buffer)
1780+
else
1781+
vim.notify("No active diff found in current buffer", vim.log.levels.WARN)
1782+
end
1783+
return
1784+
end
1785+
17451786
local tab_name = vim.b[current_buffer].claudecode_diff_tab_name
17461787

17471788
if not tab_name then
@@ -1756,6 +1797,18 @@ end
17561797
---This function reads the diff context from buffer variables
17571798
function M.deny_current_diff()
17581799
local current_buffer = vim.api.nvim_get_current_buf()
1800+
1801+
-- Check for inline diff buffer first
1802+
if vim.b[current_buffer].claudecode_inline_diff then
1803+
local tab_name = vim.b[current_buffer].claudecode_diff_tab_name
1804+
if tab_name then
1805+
M._resolve_diff_as_rejected(tab_name)
1806+
else
1807+
vim.notify("No active diff found in current buffer", vim.log.levels.WARN)
1808+
end
1809+
return
1810+
end
1811+
17591812
local tab_name = vim.b[current_buffer].claudecode_diff_tab_name
17601813

17611814
if not tab_name then
@@ -1767,6 +1820,14 @@ function M.deny_current_diff()
17671820
M._resolve_diff_as_rejected(tab_name)
17681821
end
17691822

1823+
-- Expose internal utilities for use by diff_inline.lua
1824+
M._find_main_editor_window = find_main_editor_window
1825+
M._find_claudecode_terminal_window = find_claudecode_terminal_window
1826+
M._is_buffer_dirty = is_buffer_dirty
1827+
M._detect_filetype = detect_filetype
1828+
M._get_autocmd_group = get_autocmd_group
1829+
M._display_terminal_in_new_tab = display_terminal_in_new_tab
1830+
17701831
return M
17711832
---@alias NvimWin integer
17721833
---@alias NvimBuf integer

0 commit comments

Comments
 (0)