github saberzero1/motions 1.4.0

one hour ago

Added

  • :stopinsert (:stopi), which docs/guides/plugin-integration.md has documented in three places without it existing. handleEx('stopinsert') reported unknownCommand: true and the notice Not an editor command ":stopinsert", while :startinsert resolved 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: :stopinsert is indistinguishable from <Esc> on the way out (A x on hello leaves hellox with the cursor on column 5 either way), and it is a no-op outside insert mode. It deliberately does not route through doKeyToKey(cm, '<Esc>') the way :startinsert routes through i/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 calls exitInsertMode behind an insertMode guard instead.
    • Fork: src/vim.js (defaultExCommandMap entry, exCommands.stopinsert)

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 $2 occurrences, but typing z left them at 5 and 7 instead of 4 and 9, outside the emphasis and outside the snippet's own field ranges — so CodeMirror dropped the session (selectionInsideField returns 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 own vim.snippet as 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 collapsing 4:4,9:9 to 8:8 and then rebuilding it as 4: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 (isTabstopEdit arming, isMarkerSnap extracted, the window no longer consumed by a dropped snap or by a selection-preserving transaction)
  • <Esc> ignored user mappings entirely, so :imap <Esc> … and :vmap <Esc> … did nothing. handleEsc() ran before matchCommand() 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> XY then a <Esc> yields aXYbc, where the fork produced abc; xnoremap <Esc> ll leaves visual mode active, where the fork returned to normal. Only full matches win: with inoremap <Esc>q ZZ and 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 arming insertModeEscKeysTimeout for a non-character key, so a user who bound <Esc>q would have no way out of insert mode at all. A mapping already being expanded is skipped via keyToKeyStack, which is what keeps the recursive imap <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 send 0x1b — while <C-c> is a distinct key; the two adjacent keyToKey entries therefore differ by a single noremap property. That property is the whole mechanism: commandMatches already skips user entries during a noremap expansion through startIndex, so an explicit if (noremap) return false in the new resolver was removed after no test could distinguish it.
    • Fork: src/vim.js (userEscMappingClaims() consulted by handleEsc(); noremap: false on the <C-[> and <C-Esc> entries)

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 typing returned [{4,4},{9,9}] → [{5,5},{7,7}], and keeps the repeated tabstop live for a second keystroke returned *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 at 4:4, cursor two still wrong at 7: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 — getCursorPos reports only the main range, so the previous assertions could not have seen a second cursor at all)
  • Twelve scenarios for :stopinsert and 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 :stopinsert scenarios on unknownCommand: true, inoremap <Esc> XY at abc against aXYbc, <C-[> likewise, and vnoremap <Esc> ll at normal against visual. Each of the five pre-existing passes was then broken deliberately and failed alone: dropping the keyToKeyStack check failed only the recursive imap <Esc> <Esc> (insert, expected normal); matching partials as well as full matches failed only the <Esc>q scenario (insert, expected normal); adding noremap: false to the <C-c> entries failed only the <C-c> scenario (aXYbc, expected abc); dropping :stopinsert's insertMode guard failed only the normal-mode no-op (cursor ch: 1, expected ch: 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: removing if (noremap) return false left all 12 green, because commandMatches already 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 published vim.cmd("stopinsert") recipe)
  • 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 read normal at settle, normal at +200 ms and insert at +1200 ms, while normal! a on its own read insert at all three — so the waitUntil(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 test vim.defer_fn instead of the documented vim.schedule. The direct scenario is genuinely red-first (insert against normal on the unfixed build) and covers :stopinsert reached through vim.cmd from a Lua callback, which is the substance.

Documentation

  • AGENTS.md: a bare npx wdio run does not rebuild main.js, so a source change under test is silently absent and a negative-control sabotage reports green — the plugin-side twin of the cm-buildhelper trap 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 limitations entry 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 Preview entry under the table-widget section, recording a previously unreported defect found while checking whether raw was a workaround: with the cursor inside a cell, a typed character lands at the end of the document. raw mode is CSS-only, so Obsidian's table decoration stays in the CodeMirror state. Measured with a control matrix — native in Live Preview and raw in Source mode are both correct on the identical fixture and cursor, which isolates the failure to raw in Live Preview. No snippet is involved.
  • docs/features/snippets.md: repeated tabstops documented as linked occurrences, with the table caveat.
  • docs/features/ex-commands.md and docs/reference/keybindings.md: :stopinsert/:stopi and :startinsert/:start documented, including that :stopinsert is a no-op outside insert mode and lands the cursor where <Esc> would.
  • docs/configuration/remapping.md and docs/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 exit entry, placed beside the existing noremap mapping limitation. The heading spells out "Escape" rather than <Esc> so the deep link from docs/configuration/vimrc.md has a plain-text anchor — an anchor carrying angle brackets has no working precedent in docs/, 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 arming insertModeEscKeysTimeout for 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+L is Obsidian's own editor:toggle-checklist-status hotkey and is consumed at window capture, 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

Don't miss a new motions release

NewReleases is sending notifications on new releases.