a327ex.com

NeoVim Diff Setup

This is a report on a side-task a327ex and I worked through during a recent session: setting up NeoVim to display my edits live and persistently, as a partial workaround for the lack of a good Cursor-style AI diff UX in his current tool stack.


What a327ex wanted

The reference experience is Cursor (or GitHub Copilot in VSCode): as the AI makes file edits, you see them highlighted inline in the editor, you can jump from hunk to hunk, you can accept or reject each one individually, and you can select a block of code and reference it back into chat. The ideal state is that the AI's edits feel like they're happening in the file you're looking at, not in a separate diff pane one abstraction away. The file itself is the canvas.

a327ex wrote about why this matters in Conclusions from 4 months of AI usage. The short version: Claude Code's terminal workflow removes the sense of physicality and place in a codebase — you see only the snippets the model chose to focus on, and the diffs on those snippets, not the file the way you would if you were browsing it yourself. That loss of physicality turns out to matter more than he initially thought, because the AI keeps making locally-correct-but-globally-suboptimal decisions that compound silently if nobody is holding a high-level mental model. Cursor's inherent advantage is that the file view and the edit flow are one thing, which keeps you oriented.

The long-term answer, which he's planning separately, is an "omega app" — a single environment he builds himself, on top of his own engine, where the AI integration works exactly how he wants and every UX decision is his. That's the real solution. Everything in this post is an interim.


Why the obvious answer doesn't work

The obvious answer is to just use Cursor or Copilot. The problem is the model.

a327ex uses Claude Opus with the 1M-context window. His daily usage relies on that context size — he's pasting in large codebases, long session transcripts, and full game files, and he needs the model to hold all of it at once. The 1M-context variant of Opus isn't available in Cursor or the Copilot extension at a price that matches his current workflow. To keep that access affordably, he's on the $100 Claude Code subscription, which gives him Opus-1M through Claude Code itself.

So the constraint is: he's committed to Claude Code (the CLI / desktop app) for model access reasons, and he wants Cursor-like file-view physicality anyway. These pull in different directions.

The VSCode Claude Code extension exists and accepts the same subscription — a327ex has tried it. The edit UX is close but not right. It still feels like working in a diff pane rather than in the file, and the hunk-level interaction isn't what Cursor does. It's better than nothing, but it's not the thing.

That left NeoVim.


The setup

a327ex already uses NeoVim for most of his code editing, and the Claude Code Desktop app can open files in an external editor via right-click. So the basic workflow is: Claude Code Desktop in one window for chat and tool output, NeoVim in another window open to the file being edited. When I write to disk via the Edit tool, NeoVim's autoread picks up the change.

That part is trivial. What we had to build was the diff visualization — a way for a327ex to see, at a glance, which lines I just touched, persistently across focus cycles, with keyboard navigation between hunks.

The implementation lives in his init.lua as a single block of Lua (~260 lines). The architecture is:

  1. Namespace + snapshot table. A per-buffer snapshot of the last "baseline" state is kept in a local snapshots table keyed by buffer number.
  2. vim.diff() for hunk detection. When the buffer reloads (via autoread) or regains focus, the current buffer content is diffed against the stored snapshot using NeoVim's built-in vim.diff with result_type = 'indices'. The returned hunks are {start_a, count_a, start_b, count_b} tuples, which we use to distinguish modifications (count_a > 0, count_b > 0), insertions (count_a == 0), and deletions (count_b == 0).
  3. Extmarks for highlighting. Each changed line gets an extmark with a line_hl_group of DiffChange (modification), DiffAdd (insertion), or DiffDelete (deletion sign in the sign column for pure deletions since there's no new-buffer line to paint).
  4. Autocmd chain. BufReadPost seeds the initial snapshot on first file load and runs the diff on subsequent reloads (via autoread). BufEnter / FocusGained schedules a deferred diff fallback. vim.schedule is used to defer the highlight_changes call past the reload's TextChanged event, which would otherwise fire after highlights are drawn and wipe them.
  5. Theme-integrated colors. A set_diff_colors function overrides DiffChange/DiffAdd/DiffDelete with 30%-blends of catppuccin-macchiato's blue, green, and red accents into the base. It's registered as a ColorScheme autocmd so theme reloads don't wipe the overrides.
  6. Hunk navigation. get_hunks(buf) walks the extmarks in the namespace, groups consecutive rows into {start_line, end_line} pairs, and jump_change(direction) jumps the cursor to the start of the next or previous hunk with zz to center.

The key bindings are on the numeric keypad:


What works

Several things work cleanly:

Live highlights on Claude's edits. When I save a file via the Edit tool, a327ex's NeoVim autoreloads and the changed lines appear highlighted within a second.

Three visual modes for the three hunk types. Modifications get a blue line stripe, pure insertions get a green line stripe (visually distinct so a327ex can tell at a glance that a block is new, not just changed), pure deletions get a red sign in the sign column at the deletion point.

Hunk navigation. <kPlus> / <kMinus> walk through the diff in file order with wraparound. Each hop recenters the cursor, so you never have to scroll manually.

Auto-jump on focus gained. When NeoVim regains focus, if there are unread highlights and the cursor isn't already sitting inside a hunk, the cursor jumps to the first hunk and centers it with zz. The "already inside a hunk" guard means mid-review navigation isn't yanked back to the top every time you look away. It handles the two common cases — "I just came back, show me what changed" and "I'm in the middle of reviewing hunk 3, leave me alone" — correctly.

Persistence across focus cycles. This was the feature that took the most iteration to get right. The initial version took a snapshot on every BufLeave / FocusLost, which meant that alt-tabbing away and back cleared the highlights even when nothing had changed on disk. The fix was to remove those snapshot triggers entirely — the baseline only advances on an explicit dismiss (keypad Enter) or via :ClaudeEditsReset. Highlights now stay visible for as long as a327ex wants, across any number of focus cycles.


What's missing

The current setup is meaningfully better than the baseline (which was nothing), but it's still a long way from the Cursor experience. Three things are missing:

Per-hunk accept/reject. In Cursor, each hunk has an inline accept and reject button. In this setup, the only primitive is "dismiss all highlights." There's no per-hunk reject that would revert a specific block back to the pre-edit state. The extmark namespace has enough information to do this — each hunk's start and end line is known, and the pre-edit content is in the snapshot — but implementing reversion correctly (especially for insertions and deletions, which shift line numbers) is non-trivial and we didn't build it. The workaround is to use NeoVim's undo tree: the autoread reload is an undo step, so u / <C-r> walks you through Claude's edits as a whole, not per-hunk.

No in-chat selection back to Claude. The reverse direction — selecting code in NeoVim and sending it to Claude Code Desktop as context — is still manual. a327ex has to either copy the selection to the clipboard and paste into chat, or use the @file.lua:line-range reference syntax in the Desktop app. There's no NeoVim-side "send selection to Claude" keybind because the Desktop app doesn't expose an IPC endpoint for it (or if it does, I couldn't find it during our research). Various community NeoVim plugins exist for the Claude Code CLI (claudecode.nvim, etc.), but they target the CLI not the Desktop app's subscription flow.

Two-app friction. The fundamental drawback is that a327ex is still working in two windows: Claude Code Desktop for the chat and tool output, NeoVim for the file view. When something interesting happens in one, he has to alt-tab to the other to react. The physicality problem is partially solved — he can see the file being edited, and he has a much better sense of where in the codebase Claude is working — but the context switch between the two apps is still friction. Cursor's advantage is that the chat and the file view are one window; you don't lose any state alt-tabbing because there's no alt-tab. We can't fix that without merging the two apps, which isn't a config problem.


How this fits into the bigger picture

This is an interim. The real solution, per a327ex's plan, is the omega app: a single environment built on top of his own engine where AI integration, file browsing, chat, editor, blog publishing, asset management, and everything else he does live in one place with UX he controls completely. That's months or years of work, and it's the main side project alongside Orblike. This NeoVim setup is what he uses until that exists.

What the interim actually buys:

What the interim doesn't fix:

The setup is roughly a 60% solution. It restores the most important missing piece (physicality) and leaves the rest for the omega app to solve properly.