Added
:stopinsert(:stopi), whichdocs/guides/plugin-integration.mdhas documented in three places without it existing.handleEx('stopinsert')reportedunknownCommand: trueand the noticeNot an editor command ":stopinsert", while:startinsertresolved normally — so the published Better Paste recipes, whose whole point is returning to normal mode after handing insert mode to another plugin, silently left the editor in insert mode. Measured against Neovim 0.12.5 before implementing::stopinsertis indistinguishable from<Esc>on the way out (Axonhelloleaveshelloxwith the cursor on column5either way), and it is a no-op outside insert mode. It deliberately does not route throughdoKeyToKey(cm, '<Esc>')the way:startinsertroutes throughi/A: in normal mode that would run the fork's idle-normal Escape path and fire the host's_idleEscapeCallback, which dismisses popovers and blurs non-workspace editors, so a command Vim defines as doing nothing would have visible side effects. It callsexitInsertModebehind aninsertModeguard instead.- Fork:
src/vim.js(defaultExCommandMapentry,exCommands.stopinsert)
- Fork:
Fixed
- A repeated tabstop inside Markdown emphasis lost both cursors and the whole snippet on the first keystroke in Live Preview — the follow-up half of #198. With the reported body
$1 *a$2* *b$2* $0, Tab placed cursors correctly at both$2occurrences, but typingzleft them at5and7instead of4and9, outside the emphasis and outside the snippet's own field ranges — so CodeMirror dropped the session (selectionInsideFieldreturns false) and a second keystroke produced*az*y *ybz*where*azy* *bzy*was wanted. A repeated tabstop is not an extension: the LSP snippet specification requires the occurrences to be linked ("typing in one will update others too"), VS Code and CodeMirror realise that as simultaneous selections, and Neovim's ownvim.snippetas one cursor plus mirrored ranges. Two measurements locate the defect outside the snippet machinery — the same body with no markup ($1 a$2 b$2 $0) is correct through every keystroke in Live Preview, and the emphasis body is correct in Source mode. The guard shipped in 1.3.0 covers the tabstop jump; the second exposure is the first edit at a tabstop, where the document change closes the jump's window and Obsidian's snap arrives on transactions after it. Against a multi-range selection the snap does not offset each range past its marker as it does for one cursor — instrumentation recorded it collapsing4:4,9:9to8:8and then rebuilding it as4:4,7:7, across two separate dispatches — so the window is now held for the whole macrotask rather than being consumed by the first drop. Arming also covers any document change that leaves the selection inside the active field, and a transaction that re-sets the selection it already has no longer closes the window, because closing it there leaves the snap behind it unguarded.- Plugin:
src/snippets/live-preview-guard.ts(isTabstopEditarming,isMarkerSnapextracted, the window no longer consumed by a dropped snap or by a selection-preserving transaction)
- Plugin:
<Esc>ignored user mappings entirely, so:imap <Esc> …and:vmap <Esc> …did nothing.handleEsc()ran beforematchCommand()and exited insert or visual mode unconditionally, so the mapping table was never consulted for that one key. Neovim honours it — measured on 0.12.5,inoremap <Esc> XYthena<Esc>yieldsaXYbc, where the fork producedabc;xnoremap <Esc> llleaves visual mode active, where the fork returned to normal. Only full matches win: withinoremap <Esc>q ZZand nothing bound to bare<Esc>, Neovim still exits insert mode, and honouring partials here would be worse than a deviation — the fork's insert-mode partial branch returns consumed without arminginsertModeEscKeysTimeoutfor a non-character key, so a user who bound<Esc>qwould have no way out of insert mode at all. A mapping already being expanded is skipped viakeyToKeyStack, which is what keeps the recursiveimap <Esc> <Esc>falling back to the built-in exit instead of resolving to a no-op.<C-[>now follows the<Esc>mapping and<C-c>still does not, matching Neovim, because<C-[>is Escape — both send0x1b— while<C-c>is a distinct key; the two adjacentkeyToKeyentries therefore differ by a singlenoremapproperty. That property is the whole mechanism:commandMatchesalready skips user entries during anoremapexpansion throughstartIndex, so an explicitif (noremap) return falsein the new resolver was removed after no test could distinguish it.- Fork:
src/vim.js(userEscMappingClaims()consulted byhandleEsc();noremap: falseon the<C-[>and<C-Esc>entries)
- Fork:
Tests
- Four scenarios for repeated tabstops, two of which are controls that must stay green. Red first, against a rebuilt bundle:
keeps both cursors inside their emphasis after typingreturned[{4,4},{9,9}]→[{5,5},{7,7}], andkeeps the repeated tabstop live for a second keystrokereturned*az*y *ybz*against*azy* *bzy*. Both halves of the fix were then sabotaged separately and each failed differently, which is what shows they are not one change: reverting the edit-arming reproduced the original failure exactly, while keeping the arming and consuming the window on the first dropped snap failed partially — cursor one correct at4:4, cursor two still wrong at7:7, text*azy* *ybz*— the signature of a two-transaction cascade with only the first blocked. The markup-free body and the Source-mode case are the controls: were repeated tabstops simply unsupported, both would fail too, and neither moved at any point. (#198)- Tests:
test/specs/snippets/snippet-live-preview-tabstop.e2e.ts(four scenarios, plus a multi-range selection reader —getCursorPosreports only the main range, so the previous assertions could not have seen a second cursor at all)
- Tests:
- Twelve scenarios for
:stopinsertand user<Esc>mappings, five of which are controls that were green before the change and needed their own sabotage. Red first: 7 failed, 5 passed — the four:stopinsertscenarios onunknownCommand: true,inoremap <Esc> XYatabcagainstaXYbc,<C-[>likewise, andvnoremap <Esc> llatnormalagainstvisual. Each of the five pre-existing passes was then broken deliberately and failed alone: dropping thekeyToKeyStackcheck failed only the recursiveimap <Esc> <Esc>(insert, expectednormal); matching partials as well as full matches failed only the<Esc>qscenario (insert, expectednormal); addingnoremap: falseto the<C-c>entries failed only the<C-c>scenario (aXYbc, expectedabc); dropping:stopinsert'sinsertModeguard failed only the normal-mode no-op (cursorch: 1, expectedch: 2); and making the resolver claim every Escape failed 8 of the 12, including both no-mapping controls. A sixth sabotage found dead code rather than a gap: removingif (noremap) return falseleft all 12 green, becausecommandMatchesalready enforces it, so the line was deleted and the suite re-run at 12 passing. The visual scenario asserts against the same keys typed directly instead of a literal column — CM6 reports an exclusive selection head (3) where Neovim reports an inclusive one (2), and a literal there encodes the coordinate convention rather than the mapping; the first draft failed on exactly that and the baseline measurement is what distinguished it from a real defect.- Tests:
test/specs/vim-builtin/insert-escape-mapping.e2e.ts(new),test/specs/vim-builtin/insert-escape-mapping-negative-controls.md(new, including the Neovim oracle table),test/specs/lua-doc-examples.e2e.ts(one scenario for the publishedvim.cmd("stopinsert")recipe)
- Tests:
- A
vim.schedule(stopinsert)scenario was written, found vacuous, and deleted rather than kept. It wrapped the deferred half of the same published recipe and passed against the unfixed build, which is the signal that it proved nothing. A mode timeline located why: with no fix present it readnormalat settle,normalat +200 ms andinsertat +1200 ms, whilenormal! aon its own readinsertat all three — so thewaitUntil(mode === 'normal')was satisfied on its first poll, before the leader mapping had fired at all. The classic shape of a test passing because its subject never ran. It cannot be made honest without an observable window between the callback returning and the scheduled tick, and widening the defer to manufacture one would testvim.defer_fninstead of the documentedvim.schedule. The direct scenario is genuinely red-first (insertagainstnormalon the unfixed build) and covers:stopinsertreached throughvim.cmdfrom a Lua callback, which is the substance.
Documentation
AGENTS.md: a barenpx wdio rundoes not rebuildmain.js, so a source change under test is silently absent and a negative-control sabotage reports green — the plugin-side twin of thecm-buildhelpertrap already recorded for the fork.CONTRIBUTING.md: the guard's two exposures, and why a dropped snap does not close its window.KNOWN_LIMITATIONS.md: a new### Tabstop placement limitationsentry for tabstops inside a table in Live Preview, and the repeated-tabstop support statement including where Neovim's presentation differs. Also a new### set tablewidget=raw does not accept typed text inside a table in Live Previewentry under the table-widget section, recording a previously unreported defect found while checking whetherrawwas a workaround: with the cursor inside a cell, a typed character lands at the end of the document.rawmode is CSS-only, so Obsidian's table decoration stays in the CodeMirror state. Measured with a control matrix —nativein Live Preview andrawin Source mode are both correct on the identical fixture and cursor, which isolates the failure torawin Live Preview. No snippet is involved.docs/features/snippets.md: repeated tabstops documented as linked occurrences, with the table caveat.docs/features/ex-commands.mdanddocs/reference/keybindings.md::stopinsert/:stopiand:startinsert/:startdocumented, including that:stopinsertis a no-op outside insert mode and lands the cursor where<Esc>would.docs/configuration/remapping.mdanddocs/configuration/vimrc.md: new sections on remapping<Esc>, covering the three behaviours a user has to know before binding it — exact matches only,<C-[>following the mapping while<C-c>stays a dependable escape hatch, and a recursive mapping falling back to the built-in exit.KNOWN_LIMITATIONS.md: a new## Only an exact Escape mapping overrides the built-in mode exitentry, placed beside the existingnoremapmapping limitation. The heading spells out "Escape" rather than<Esc>so the deep link fromdocs/configuration/vimrc.mdhas a plain-text anchor — an anchor carrying angle brackets has no working precedent indocs/, and the one existing link to a heading with backticks and slashes (ex-commands#ob--obcommand--execute-obsidian-commands) uses the slugified form instead. It records that the full-match-only rule is a safety property as well as a parity one: the engine's insert-mode partial branch consumes the key without arminginsertModeEscKeysTimeoutfor a non-character key, so honouring a partial would swallow<Esc>indefinitely and leave no way out of insert mode.AGENTS.md: user<Esc>mappings and the<C-[>-versus-<C-c>asymmetry recorded in the fork description, and a note that a bad probe key can look like a missing feature —Ctrl+Lis Obsidian's owneditor:toggle-checklist-statushotkey and is consumed atwindowcapture, which is why an insert-mode function keymap bound to it appears never to fire while the same mapping on<A-y>fires normally.
Full Changelog: 1.3.1...1.4.0