# VSNeo — working context Visual Studio keeps its editor. Neovim is the brain behind it. Neovim owns Vim semantics (mode, motions, operators, registers, marks, macros, command line). Visual Studio owns rendering, IntelliSense, refactorings, and undo. They are kept in sync through a mirrored buffer. This is deliberately **not** the "render nvim in a window" approach. We consume `nvim_ui_attach` as a *state feed* and let VS keep drawing the text, so Roslyn features survive. Do not propose embedding a grid renderer or reparenting Neovide — both were considered and rejected. ## The invariant everything follows from **The key handler decides swallow-vs-passthrough from a locally cached mode, with zero I/O. The effect of the key travels over RPC and lands a few milliseconds later.** Decision local and synchronous; effect remote and async. Non-negotiable corollaries: - `TextViewCreated` does bookkeeping only. Eager work there froze VS once already. No service resolution, no process start, no RPC. - No `JoinableTaskFactory.Run`, no `.Result`, no `.Wait()` in the startup path or the key path. `RunAsync` only. - Fallback is a session-level circuit breaker, never a per-keystroke retry. Half-swallowed input leaves the buffers drifting and is worse than being off. - Insert mode passes through to VS untouched so IntelliSense, snippets, and brace completion keep working. `` is the only key claimed in insert. ## Constraints - VSIX, .NET Framework 4.8. This is fixed: in-process extensions share `devenv.exe`, which is Framework-based. Running out-of-process is not an option — the new VisualStudio.Extensibility model has no keyboard interception surface, and it would add a second RPC hop per keystroke. - In-proc VisualStudio.Extensibility *can* be added alongside the MEF parts for commands/settings/tool windows. It is additive, planned for milestone 3, and changes nothing about the key path. - Target VS 2022 (17.x) and VS 2026 (18.x). ## Layout SDK-style VSIX project, net472. The earlier hand-written `src/VSNeo/VSNeo.csproj` is gone: it lacked the project-type GUIDs, so F5 refused to launch it. VSNeo_Extension/ VSNeo_ExtensionPackage.cs AsyncPackage, background load, owns nvim lifetime Nvim/MsgPack.cs hand-rolled msgpack: reader, writer, stream framer Nvim/NvimRpcClient.cs msgpack-rpc over a named pipe ([0,id,method,params]) Nvim/NvimSession.cs attach/activate split, nvim_input, ui_attach, Lua companion Nvim/NvimLua.cs loads Lua/vsneo.lua from beside the assembly Lua/vsneo.lua the companion: state over rpcnotify, options, VS command mappings Nvim/NvimStateHub.cs companion rpcnotify -> cached mode + cursor; redraw -> cmdline + messages Editor/VsNeoKeyProcessorProvider.cs the synchronous decision point (WPF keys) Editor/VsNeoCommandFilter.cs IOleCommandTarget, for keys VS took first Editor/IntelliSenseGate.cs is VS's own UI owed this keystroke? Editor/KeyEncoder.cs WPF keys -> nvim notation Editor/BufferMirror.cs VS -> nvim: one nvim buffer per document, edits as spans Editor/CursorSynchronizer.cs both directions, off the key path Editor/ViewportSynchronizer.cs grid size + topline, for /H/M/L/zz Editor/TextViewCreationListener.cs bookkeeping only, see invariant Editor/CmdLineMargin.cs draws ext_cmdline Editor/MessageMargin.cs draws ext_messages Editor/RelativeLineNumberMargin.cs relative line numbers, Vim-style Infrastructure/CircuitBreaker.cs Infrastructure/ProcessJob.cs KILL_ON_JOB_CLOSE, so nvim cannot orphan Infrastructure/ColumnMapper.cs byte <-> char, single source of truth Infrastructure/Log.cs lifecycle diagnostics -> %TEMP%\vsneo.log **Two interception points, by necessity.** The KeyProcessor sees WPF key events; anything Visual Studio has already turned into a command never reaches it. `Escape` is the case that proves it - VS routes it as VSStd2K `CANCEL` through `IOleCommandTarget`, so `PreviewKeyDown` is never called for it. `Ctrl+[` is the same story. Characters and chords go through the KeyProcessor, commands through `VsNeoCommandFilter`. ## Visual Studio commands from Vim mappings The reason for keeping VS as the editor, and the thing neither tool gives you alone. `vsneo.cmd(name)` runs any command by the name in Tools > Options > Keyboard; `vsneo.goto_cmd(name)` does the same after recording the jump, so `` comes back from it. vim.keymap.set('n', 'b', function() vsneo.cmd('Build.BuildSolution') end) Defaults wire `gd`, `gD`, `gi`, `gr`, `[d`, `]d` to Roslyn's navigation, and `K`, `rn`, `ca`, `f` to quick info, rename, quick actions and format. Vim's own `gd` is a same-file text search and is strictly worse here. Folding is Visual Studio outlining, mirrored into nvim as manual folds (`Editor/FoldSynchronizer.cs` + the fold section of `vsneo.lua`). Region boundaries always come from Visual Studio - they are the language service's - and the closed/open state syncs both ways: VS → nvim rides the `RegionsCollapsed`/`RegionsExpanded` events plus a full push on every document switch (manual folds are window-local in nvim and do not survive one); nvim → VS is polled, because nvim has no fold-changed event - the companion diffs `foldclosed()` against the last agreed state on every state push and reports the actual closed set, which FoldSynchronizer reconciles into outlining. Line edits shift every fold on both sides (each tracks its own), but the companion's agreed boundaries go stale, so its detection is gated on `changedtick` and FoldSynchronizer resends the full region set 400 ms after editing pauses - without that pair, an edit above a collapsed region phantom-reported and expanded the fold. Either side's answering push compares equal against its agreed copy and no-ops, so there is no toggle loop. `za`/`zo`/`zc`/`zd`/`zR`/`zM`, the `zj`/`zk`/`[z`/`]z` motions, counts over folds, and `'foldopen'` auto-opens (search, `%`, marks) are all native nvim behavior now. `zf` is the one mapped key: an nvim-only fold would be transient (the next full sync recreates only VS-known regions), so the range goes to Visual Studio, where `Editor/UserFoldTagger.cs` turns it into a real outlining region (a custom `ITagger` over an in-memory, per-buffer store; the tag type marks user folds), and the `RegionsCollapsed` event round-trips back into nvim as the manual fold. `zd` splits by origin: a user fold's region is removed, a language fold only expands and stays collapsible. User folds are session-scoped - VS recreating the ITextBuffer for a reopened document drops them, matching nvim's manual-fold lifetime. The `:fold` ex-command stays native and nvim-only (transient). nvim folds are line-granular while VS extents can start mid-header-line; the fold spans `[extent start line, extent end line]`, which keeps cursor and motion semantics aligned (the fold start is the header line in both). As a safety net for sync gaps, a motion that still lands strictly inside a collapsed region is snapped Vim-style (`CursorSynchronizer.SnapOutOfCollapsedRegion`): just past the region when moving down, onto the fold's header line when moving up, and the snapped position is pushed back to nvim. Window management is mapped the same way. `:split`/`:vsplit` and the `Ctrl-w` family call `Window.Split`, `Window.NewVerticalTabGroup`, `Window.NextSplitPane` and friends. `Ctrl-w h/j/k/l` are directional for real: Visual Studio has no directional "go left / go right" command between splits, so the Lua mapping sends a `vsneo_focus` notification and `Editor/SplitNavigator.cs` resolves the adjacent tab group from the on-screen document frames' geometry. The same class owns ``: a most-recently-used walk over the frames it has seen focused (first press is the alternate-file toggle, repeated presses walk deeper), and `Editor/TabJumper.cs` owns `gb`, a PeasyMotion-style labeled tab jump driven through tab-caption overrides. `Ctrl+W` is unbound from `Edit.SelectCurrentWord` by `KeyBindingCleaner` so it can serve as the window prefix. `:e` is routed the same way: an `:Edit` user command (uppercase, plus a position-guarded cmdline abbreviation, like `:Vsc`) runs `File.OpenFile`. Left to nvim it would load the file into a buffer nvim owns - VS never opens it, the mirror drops its edits as "some other document", the state pushes keep reporting positions from a file nobody shows, and a later mirror for the same path hits E95 naming its buffer. A bare `:e` reopens the current document in VS; a directory argument (`:e .`) is an explorer request and routes to the Solution Explorer instead. netrw itself cannot work here - its directory buffers are foreign buffers VS can never show - so its `:Explore` family is force-overridden (the plugin loads before the companion, so preventing the load is not an option): every variant runs `SolutionExplorer.SyncWithActiveDocument` + `View.SolutionExplorer`, VS's equivalent of "explore from here". The other doors into foreign buffers are intercepted too: `:b`/`:buffer` resolve against nvim's buffer list and open the target in VS (a `:Buffer` user command plus abbreviations), `:bn`/`:bp` ride `Window.NextTab`/ `PreviousTab` (VS is the window manager; tab order, not buffer-list order). `gf` is deliberately not mapped - configs bind it themselves (the sample rc sends it to `Edit.GoToFile`), and native `gf` lands on a real file through the follow logic anyway. And any nvim-initiated switch that still lands on a real file - a global mark, a cross-file ``, a plugin - is *followed*, not snapped back: `TextViewCreationListener` runs `File.OpenFile` on it, and the normal attach path takes over. A file nvim loaded itself is adopted by the mirror (`vsneo.find_buffer`, or `nvim_buf_set_name` would hit E95); the prime makes VS's text win, discarding unsaved edits a plugin made in nvim's copy - VS owns files. Snap-back remains for buffers that cannot be documents: unnamed, scratch, netrw's directory views, deleted files. ## Milestones 1. **Mode and navigation** — read-only mirror, `nvim_input` for motions, cursor applied back, mode in status bar. No operators. *(done)* 2. **Operators** — `nvim_buf_attach` + `on_lines` applied back into VS, grouped into `ITextUndoHistory` transactions. *(done)* 3. **`ext_cmdline`** — real `:` and `/`, drawn by `CmdLineMargin` below the text. Keys VS claims as commands (Enter above all) are routed in `VsNeoCommandFilter.TryHandleCmdLine`, scoped to CmdLine mode. `:%s/a/b/g` works end to end. *(done)* 4. **`ext_messages`, search highlights, `hlsearch`.** *(done: ext_messages margin, mode text, and hlsearch adornment all implemented)* 5. **Opt-in config loading** — `vim.g.vsneo` is already set. Text-manipulating plugins will work; anything drawing its own UI will not. ## Open work and known issues - `Ctrl+F` is still VS's Find. Deliberate: Vim's replacement is `/`, which is now drawn by `CmdLineMargin`. Add it to `KeyBindingCleaner.Chords` if you want Vim's page-forward instead. - `KeyBindingCleaner` unbinds through DTE, and those writes only reach disk on a clean shutdown - a killed instance loses them and the chord is bound again next launch. In practice that is harmless: the cleaner re-runs at every startup, so the loss self-heals. The bug that actually bit was timing, not persistence - the pass used to wait for nvim readiness (`OnReadyChanged`), leaving the chord prefixes dead for the first seconds of a session. It now runs at package load, in parallel with nvim startup, and verifies the bindings it touched in the same enumeration instead of walking every command twice. Removing a binding by hand in Tools > Options > Keyboard still persists more durably than any of this. - `ViewportSynchronizer` cannot represent a VS viewport scrolled past the end of the file: nvim clamps its topline to `lineCount - height`, so the report that comes back matches nothing in the echo ring and applying it yanks the view back up. Topline is not pushed there, and nvim's scroll reports are ignored until the view is back in range. A caret scrolled off screen is no longer such a state: `note_viewport` clamps nvim's cursor into the window (Vim's own rule - the cursor never leaves the screen), flagged synthetic so Visual Studio's caret stays put. `H`/`M`/`L`, `zz` and the first motion after a wheel-scroll all compute against what is on screen; nvim's cursor rejoins the caret the moment it scrolls back into view. Whether the caret is on screen is decided by VS's laid-out lines (a hidden or scrolled-out position has none), not by topline + height arithmetic - a collapsed outlining region compresses the view, and arithmetic called such a caret "outside the window" while it was plainly visible, so the next motion snapped back to the window edge. - Drift between the two buffers is repaired by comparing them 500ms after editing stops (`BufferMirror.Verify`). Now a safety net rather than the mechanism, since `on_lines` applies nvim's edits directly. A large drift (for example from an external file change or a reload) re-primes nvim from Visual Studio instead of stopping the mirror. Only five consecutive failed repairs stops it. The delay doubles per consecutive drift, capped at 30s. - `$` in blockwise visual runs to the end of every line. The companion reads that state off `curswant == v:maxcol` and flags it in `vsneo_state`; `CursorSynchronizer.ApplyRaggedBlock` then draws one selection per line through the multi-selection broker (`ITextView2.MultiSelectionBroker`), skipping lines shorter than the block's left edge, as Vim does. The broker draws an insertion point per line, so the ragged block shows multiple carets where Vim shows one - the region itself is exact. Without the broker the rectangle fallback draws to the cursor's column instead. - `ext_messages` shows `msg_show` and `msg_showmode` content in `MessageMargin`, plus `msg_showcmd` partial commands (`d2`, `"ay`) in a right-aligned block of the same margin, where Vim draws them. - `"` in normal/visual mode opens the register peek (`RegistersPopup.cs`): the key still goes to nvim, and a fire-and-forget `vsneo.registers()` call collects the contents in one round trip. Dismissal rides `ShowCmdChanged` - nvim clears showcmd when the pick resolves or Escape aborts it - so the key path never tracks the popup. Fast typists are guarded by re-checking ShowCmd when the reply lands. - Search highlights are drawn by `SearchHighlightAdornment`. nvim computes the matches (`vsneo.lua` uses `vim.regex` so Vim syntax works unchanged) and sends them as `vsneo_search_matches`; the extension draws background rectangles for the visible lines. Highlights appear only in the focused view. - Relative line numbers are drawn by `RelativeLineNumberMargin`, **currently disabled**: its `[Export]` is commented out. It repainted on every caret move and every layout, building one WPF `FormattedText` per visible line each time - about fifty text-shaping runs per keystroke in insert mode, on the UI thread, for decoration. Re-enable by restoring the export; to avoid two line-number columns then, disable Visual Studio's own line numbers in Tools > Options > Text Editor > General. - A view focused before nvim finishes starting used to leave the key processor swallowing motions into nvim's startup buffer while the editor appeared frozen. `TextViewCreationListener` now queues those views and attaches them when the session becomes ready. ## Known landmines - **nvim stdio does not work from .NET on Windows.** `Process` with `RedirectStandardInput/Output` creates *synchronous* anonymous pipes. nvim's stdio is libuv-backed and wants overlapped handles, so `--embed` over redirected stdio dies ~100ms after start: exit code 1, empty stderr, not one byte written. The same nvim answers perfectly over a shell pipe or a file, so this reads as a bug in your encoder for as long as you let it. Fixed by `--listen` onto a named pipe plus `NamedPipeClientStream` with `PipeOptions.Asynchronous`. Do not "simplify" this back to stdio. - **MessagePack version conflict.** *(resolved - keep it that way.)* VS loads its own `MessagePack.dll` in-process, and a VSIX that ships a second copy fails as unrelated MEF composition errors that never mention MessagePack. `Nvim/MsgPack.cs` now owns the subset nvim needs, so the VSIX ships `VSNeo.dll` and nothing else. Reintroducing the package also drags back eight transitive assemblies (`System.Memory`, `System.Collections.Immutable`, `System.Runtime.CompilerServices.Unsafe` and friends) that collide with VS internals far more readily than MessagePack does. - **A null `changedtick` is not an edit.** `nvim_buf_lines_event` carries `v:null` where the tick goes when only the *display* changed - which is what `'inccommand'` does while you type `:%s/foo/bar/`. It really edits the buffer, shows you the result, and reverts it. The revert is not a buffer change and emits no event, so applying the preview writes it into the real file and leaves it there. `BufferMirror.OnRemoteLines` drops null-tick events, and `vsneo.lua` sets `inccommand=''` so they are never sent. Verified against real nvim: the preview arrives, and `ToLong(null)` returning -1 was one missing guard away from corrupting a source file. - **One nvim buffer, one writer.** `BufferMirror.Registry` deliberately shares an nvim buffer between every `ITextBuffer` for the same path - that is what fixed the E95 name collision. What it must never share is two *live* mirrors: VS hands out a fresh `ITextBuffer` whenever a document is reopened, and both mirrors then repaired drift against the same nvim buffer, each resending its own snapshot over the other's every 500ms. Observed on an idle editor: 57 lines against 31, alternating at 2Hz for ninety seconds. Always construct through `BufferMirror.ForDocument`, never `new`. - **`nvim_buf_detach_event` arrives unannounced.** nvim unhooks the channel whenever a buffer is unloaded or reloaded and says nothing further, so a mirror that ignores it keeps running against a buffer it no longer hears from and every operator silently stops arriving. `OnRemoteDetached` reprimes. - **Byte vs char columns.** nvim columns are UTF-8 byte offsets; VS wants UTF-16 char offsets. All conversion lives in `ColumnMapper`. Test with emoji and accented Latin early. - **Undo ownership.** Two undo stacks is unwinnable. VS's `ITextUndoHistory` is authoritative; `u` and `Ctrl+R` are intercepted in `VsNeoKeyProcessor` (normal mode only) and executed against the view's history, never forwarded to nvim. We give up nvim's undo tree to keep undo-a-Roslyn-rename working. This is load-bearing, not a preference: nvim's undo tree reaches back to the mirror's initial *empty* buffer, so a forwarded `u` walks it down to nothing and the mirror applies every step into VS - which is how pressing `u` once too often emptied whole files before the interception existed. - **VS global keybindings** win before the key processor sees some chords. `Ctrl+[` is the classic casualty. Handle `IVsFilterKeys2.TranslateAcceleratorEx` or remove the conflicting bindings. ## Build Windows, extension development workload. The solution is `.slnx`, so VS 2022 17.14+ or VS 2026 — earlier 17.x cannot open it. F5 launches an experimental instance (`/rootsuffix Exp`). Set `VSNEO_NVIM_PATH` if `nvim.exe` is not on PATH. msbuild VSNeo.slnx -restore -p:Configuration=Debug