github saberzero1/motions 1.1.0

2 hours ago

Added

  • Smart list continuation now continues blockquotes and callouts — o/O on > quote, a > [!NOTE] title or body line, a nested > > quote, an indented > quote, or a spaceless >quote previously produced a bare empty line, because the marker group in LIST_CONTINUATION_RE was mandatory and a quote with no list marker never matched. Measured against Obsidian's own Enter key over 31 line shapes, o disagreed with Enter on seven of them, all of them quote-only lines; the plugin's own Neovim backend already continued every one of those, since the stock Markdown ftplugin carries n:> in comments and the RPC projection preserves it. The marker group is now optional, and a match with no marker carries the indent and quote chain alone. The same change adds a padding group between the quote and the marker, so a list indented inside a quote (> - indented) keeps both rather than matching nothing — without it, making the marker optional would have continued that line as > and silently dropped the bullet. Behaviour with a marker present is unchanged, and the fenced-code-block exclusion still applies to quote-only lines, including fences written inside a blockquote. (#195)
    • Plugin: src/actions/open-line.ts (LIST_CONTINUATION_RE, detectListContinuation)
  • The second half of #195 — o on a parent item inserting at the child's indent level — is not implemented. Obsidian's own Enter, Neovim (both stock and under the RPC projection), and the bundled engine all insert at the parent's level; it was measured on tab- and space-indented children in all three. It is genuine obsidian-outliner behaviour, guarded there by conditions this implementation has no line-local way to express (o only, parent unfolded, cursor at end of line, indent and bullet copied from the first child), and it is not a shared outliner convention either — Logseq agrees with it, Workflowy's documentation says Enter creates a node at the same level. Adopting it would make o disagree with Enter inside one editor, which is the inconsistency the first half of the issue reports. It remains available from Lua.

Fixed

  • The Neovim backend no longer rewrites tab indentation as spaces — with the backend connected, o on a tab-indented list child produced a space-indented line, so every continuation in a tab-indented vault silently changed the document's indentation convention. Vim rebuilds a continued line's indent from its column count rather than copying the original bytes, and Neovim's Markdown ftplugin sets expandtab with tabstop=4; nothing forwarded Obsidian's own indent settings, so the mirror always re-emitted spaces. Obsidian's useTab and tabSize now become the mirrored buffer's expandtab/tabstop/shiftwidth/softtabstop, which also governs >>, << and ==. Hardcoding expandtab=false is not the fix and was measured to invert the bug — a space-indented vault then has its four-space child rewritten to a tab — so both directions are asserted. Found while building the three-way comparison for #195; the three remaining divergences there are inherent to comments and stay documented. (#195)
    • Plugin: src/rpc/document-sync.ts (NeovimEditorOptions.indent, APPLY_EDITOR_OPTIONS_LUA), src/main.ts (new vaultIndentStyle())
  • zz, zt and zb now honour scrolloff — they reached the scroll position through scrollDOM.scrollTop without dispatching a CodeMirror transaction, and the plugin's margin enforcer is an updateListener gated on update.selectionSet, so it never saw them; scrollToCursor had no scrolloff term of its own either. The margin was therefore ignored on every one of these commands, at the default scrolloffLines of 5 as much as at a configured value. Measured against Neovim 0.12.5 (nvim --clean, 80x23, wrap, nosmoothscroll), it reaches them in two distinct ways. zt and zb hold the margin past the cursor line on any line — Neovim moves topline from 61 to 56 for zt at so=5, where the plugin left 0 rows — and both stop at the centred position once the margin no longer fits, which is why zt, zb and zz all report topline 50 at so=11 and never move again at so=12 or so=9999. Inside a line taller than the window, where Neovim scrolls by skipcol rather than by topline, the margin instead applies to the cursor's own display row: so=5 gives skipcol 400 and winline 18 against the plugin's 23, and so=9999 gives skipcol 880 and a centred winline 12. zz is untouched on a line that fits, because zz only ever sets a whole-line topline; and the margin stays out of reach at a tall line's first and last display row, where skipcol saturates at 0 and at lineRows - winheight — which is why a cursor at the end of a very long wrapped line sits on the bottom row at every scrolloff, exactly as reported in #183. Built-in vim mode is unaffected; it uses Obsidian's bundled codemirror-vim. (#183)
    • Fork: src/vim.js (scrollToCursor, new Vim.setScrolloffSource)
    • Plugin: src/vim/scrolloff.ts (new getScrolloffLines), src/vim/bundled-vim.ts
  • A folded heading no longer repeats its own title — folding # Introduction rendered the heading and then a greyed-out copy of the same text beside it, so a long title cost roughly double the width when folded, and Obsidian's own fold shows … there. A heading fold range starts at the end of the heading line, so the heading is never hidden and the title in the placeholder was always a duplicate of text still on screen. The placeholder now carries only what the visible line does not, — N lines, which is also what the nested-list folds in the report show. Both branches changed, the treesitter metadata one and the regex fallback, so the label no longer depends on whether the parser is ready. An empty ATX heading (#) also stops emitting the stray leading space its empty title used to produce. Fenced code, callout and frontmatter placeholders are untouched — the report is about the heading case, where the title is on screen in both editing modes. (#193)
    • Plugin: src/fold/placeholder.ts
  • Undo no longer empties a note under the Neovim backend — this was data loss, not a display fault. The backend mirrors every note into one buffer created once in NeovimDocumentSync.start() and renamed and reseeded on each activation, and undo history belongs to the buffer rather than to the note. Nothing reset it, so the oldest reachable entry was the buffer's original empty state: pressing u on a freshly opened note emptied it, the line events mirrored that into CM6, and Obsidian saved the result. Measured on a two-note workspace, undotree().seq_last was 4 where a freshly activated note must report 0, u replaced the note's two lines with "", and the emptied buffer reached disk through the vault adapter. The reseed now runs at undolevels = -1, which discards the previous note's history; reading the option yields the -123456 "use the global value" sentinel when no buffer-local value is set, so saving and restoring it pins nothing — verified by confirming a later vim.go.undolevels = 500 still reaches the buffer. The :help clear-undo recipe also worked but left seq_last at 2 and modified buffer content, so it was rejected. Ordinary within-note undo is unaffected and is asserted separately, because the obvious over-correction — clearing on every sync rather than on activation — would satisfy every other assertion and break editing
    • Plugin: src/rpc/document-sync.ts (new RESEED_BUFFER_LUA)
  • The Neovim insert-completion popup no longer renders far from the cursor — it was anchored at column × defaultCharacterWidth and (row + 1) × defaultLineHeight, terminal-grid arithmetic against a fixed 120x40 grid that bears no relation to proportional Markdown typography, wrapping, folds or the scroll offset. Measured 806.9px of horizontal drift from the cursor on a 45-character line. It now anchors at view.coordsAtPos(), the same choice the float bridge already makes for relative = 'cursor'; grid cells remain only as the fallback for when CM6 cannot resolve a cursor rectangle. Anchoring alone left 399.5px, because popupmenu_show and the buffer's line and cursor notifications share one RPC stream with no ordering guarantee, so the cursor read during the show predated the edit that produced the completion — instrumenting it showed the vertical anchor already exact and only the horizontal lagging. Deferring a frame did not help and reproduced 399.531 byte-for-byte, so the re-anchor is driven from a new document-sync observer that fires once CM6 holds Neovim's text and cursor
    • Plugin: src/rpc/popupmenu.ts (new reanchor()), src/rpc/document-sync.ts (new setMirrorObserver()), src/rpc/neovim-connection.ts
  • The cursor shape now follows Neovim's mode with the animated cursor off — which is the default, so with stock settings the cursor was permanently a block in every mode. The backend reported the mode correctly; nothing rendered from it. The bundled fork draws the visible cursor and resolves its shape from its own cm.state.vim, and its keydown observer returns early while setKeyInterceptActive is set, so that state never leaves normal; the animated canvas cursor was the only renderer consulting the external mode and it is opt-in. The fork gains a setExternalCursorMode() override that feeds the cursor renderer alone. Two constraints on it are load-bearing and were established by measurement: it must not write cm.state.vim, which the status bar and mode tracker share, and it must not redraw with a transaction, which disturbs the RPC composition input. A host-side attempt that violated both turned rpc-ime.e2e.ts and rpc-lifecycle.e2e.ts from 5 and 13 passing into 3 and 2 failures and was reverted; those two specs are the gate for any further cursor work. Because an external mode change produces no ViewUpdate, the fork keeps a registry of live BlockCursorPlugin instances and refreshes them through requestMeasure
    • Fork: src/block-cursor.ts (new setExternalCursorMode, getExternalCursorMode, effectiveVimState, applyCaretColor, refreshExternalMode), src/index.ts
    • Plugin: src/vim/external-mode.ts
  • Neovim's visual selection is now visible — with the backend connected, entering any visual mode highlighted nothing. The bridge sent a caret and only a caret: vim_motions_cursor carries (buf, row, col) and syncCursor() dispatched selection: { anchor }, so CM6's selection was empty even while Neovim reported mode v, measured as {from: 2, to: 2} with zero rendered selection elements. Charwise, linewise and blockwise selections now cross, with the head character included as Neovim includes it — a CM6 range excludes its to, so the end carrying the cursor is extended by one code point rather than by one unit, which would split a surrogate pair. Blockwise becomes one range per row; its columns are byte offsets rather than display cells, so a block over rows of differing width is approximate in the same way as the rest of the bridge's cell mapping. The extra getpos('v') round trip happens only in a visual or select mode, so ordinary typing is unaffected. The range is rendered as a decoration, not as CM6's selection: mirroring it into EditorSelection renders identically and makes Obsidian consume the Escape keydown before the delegation listener sees it, so visual mode could be entered and never left — measured as Neovim staying in v across <Esc>, and unaffected by clearing the DOM range, while disabling the mirroring restored n
    • Plugin: src/rpc/visual-selection.ts (new), src/rpc/document-sync.ts (syncSelection, visualRanges, dispatchCursor), src/rpc/key-delegation.ts (visualKind, syncVisual), src/main.ts, styles.css
  • A language server now attaches to every note, not just the first — the RPC backend mirrors every note into one buffer that is renamed in place, which broke a language server two ways. vim.lsp.enable() attaches on FileType and skips a buffer whose 'buftype' is already set, so once the first activation left the mirror acwrite no later activation could attach anything; and the rename sent no didClose/didOpen, so a server that was attached went on attributing edits to the previously active note. activateDocument() now detaches any attached client before the rename and clears 'buftype', restoring acwrite once the content is in place. Measured against an in-process server: switching notes emits didClose for the old path and didOpen for the new one, and a vim.lsp.enable() registered mid-session attaches on the next activation, where a bare filetype detect still does not — that is Neovim's own rule, and it is what the buftype clear works around
    • Plugin: src/rpc/document-sync.ts (new PREPARE_ACTIVATION_LUA)
  • Diagnostic signs, virtual lines and whole-line highlighting now cross the Neovim bridge — ForwardedExtmark carried hl_group, virt_text and priority and nothing else, so three ordinary persistent extmark fields that the redraw-time forwarding already saw rendered as nothing. virt_lines now becomes a CM6 block widget, line_hl_group a line decoration, and sign_text/sign_hl_group reach the sign gutter. That gutter had to become multi-source first: setSignsEffect replaced the whole set and only the mark gutter dispatched it, so backend signs and mark signs would each have erased the other; both sources are now held in the field and merged per line, behind a signMarkers() accessor so consumers do not depend on the shape. This is what diagnostic signs, diagnostic virtual_lines, 0.12 code lens and gitsigns are expressed in. number_hl_group and conceal remain dropped
    • Plugin: src/rpc/companion.lua, src/rpc/decorations.ts (NeovimVirtualLinesWidget, signEntries), src/vim/sign-column.ts (new setRpcSignsEffect, signMarkers), src/vim/statuscolumn.ts, styles.css
  • Neovim's quickfix list is browsable from the picker — getqflist() returns structured entries rather than screen cells, so it crosses the bridge without any grid reconstruction, and the picker already had a provider API to receive it. A quickfix source and a Picker: Quickfix list command surface whatever :grep, :vimgrep, vim.diagnostic.setqflist() or an LSP reference list populated, grouped by severity, with entries converted from Neovim's absolute paths to vault-relative ones — an absolute path there produces an entry Obsidian cannot open. Entries outside the vault are listed but not navigable, because they have no vault path rather than a wrong one. The source yields nothing while the backend is disconnected. This does not add :cnext/:copen to the bundled engine, which still has no quickfix model of its own
    • Plugin: src/picker/sources/quickfix.ts (new), src/main.ts
  • Quickfix entries were unopenable on macOS — vaultRelative() compared Neovim's absolute path against Obsidian's vault base with a plain prefix test, and macOS reaches /var through a firmlink to /private/var. Neovim resolves the path when it names a buffer while Obsidian's adapter reports the base unresolved, so the prefix never matched: every entry rendered as an absolute path and onSelect silently did nothing, because a null path is how the source represents "outside the vault". Both ends are now normalised. Caught by the macOS CI shard rather than locally, since Linux has no such firmlink
    • Plugin: src/picker/sources/quickfix.ts (stripPrivate, vaultRelative now pure and taking a base path)
  • :quickfix opens the quickfix picker, matching the ex command every other picker source already had. It carries no short form: Vim.defineEx requires the short form to be a prefix of the name, qf is not one, and Vim spells this :copen rather than :qf anyway
    • Plugin: src/workspace/commands.ts

Tests

  • 6 e2e tests in test/specs/vim-builtin/z-commands.e2e.ts for the scrolloff fix, every expected value taken from a headless Neovim 0.12.5 run rather than written by hand. Three cover the tall-line skipcol regime — 5 rows below the cursor at so=5, a centred cursor row at so=9999, and a line that fits the viewport staying put at both so=0 and so=9999 — and three cover zt/zb on an ordinary line, including the convergence property that zt, zb and zz produce one identical position once so exceeds half the window. Red first: the two tall-line tests failed at 1 row below the cursor against 5, and at 1.42 rows below against a required quarter-viewport. The two pre-existing zt tests in the #143 block were rewritten in the same units for the same reason: zt should place cursor line within top 15% of viewport asserted a flat 15% where the correct value is now scrolloff / viewportRows, and zt should place cursor line near the top of viewport, not center required the zt/zz gap to exceed 30% of the viewport, which the margin eats on a short window. Both encoded a zt that sat flush at row 0. They passed on this 54-row machine and on CI's 37-row Linux runner and failed on its ~25-row macOS and ~27-row Windows runners, at 0.202 against 0.15 and 161 against 173; the first was renamed to zt should place cursor line at the scrolloff margin, not the centre because its old name named the threshold. Both now assert in display rows against the measured viewport and the configured scrolloff, and both were negative-controlled by making zt return zz's position — the #143 defect itself — which failed them at 22.10 and 21.32 rows against tolerances of 2 and 1. Both of the new tall-line assertions are expressed against the measured viewport too, because the first versions were calibrated to one machine and failed CI on all three platforms: Math.round() on a value a sub-pixel below zero returns -0, and expect(-0).toBe(0) fails Object.is, so the last-visible-row check now reads Math.abs(rows) < 0.5; and a hardcoded > 20 rows described a 54-row viewport, where CI's is ~37. Both were reproduced locally by shrinking the editor to 35.9 rows — -0 and 17.44 against 20, the same two failures — and both pass at 35.9 and 54.2 rows after the change. Negative-controlled afterwards in two independent directions — restoring the pre-fix zt/zb position cases failed all three of their tests (0 rows against 5, 0 rows against 5, blockTop 0 against 632), and dropping the taller-than-viewport gate so the margin leaks onto a line that fits failed only the fits-the-viewport test (blockTop 447 against 428), which is the assertion that gate exists for. Both controls left the other tests green, so each is targeted rather than a blanket break
  • 2 e2e tests in test/specs/vim-builtin/z-commands.e2e.ts for #183 — the follow-up comment reports zz centring the wrapped line rather than the cursor, and leaving the cursor at the bottom of the screen on a very long line. Both were measured against Neovim 0.12.5 (nvim --clean, 80x23, wrap, scrolloff 0, nosmoothscroll) and both are what Neovim does, so the tests pin them instead of changing them. The first runs zz three times on one 18-row line with the cursor at its first, middle, and last character and asserts an identical scrollTop — Neovim reports topline 35 for all three, with only winline moving (7, 12, 17). The second puts the cursor on the last character of a line twice the viewport height and asserts it lands on the last visible row, matching Neovim's winline 23 of 23 at skipcol 80/560/2960 for lines of 24/30/60 rows. Negative-controlled by rewriting the center case of the fork's scrollToCursor to centre charCoords(cursor).bottom: the first failed with scrollTop 2518 against 2326 (8 display rows of drift from a cursor-column change alone), the second with 27 rows below the cursor instead of 0. The column-independence precondition is measured from the line's own first row rather than the viewport, because a viewport-relative one is constant under that same sabotage and tripped before the assertion it guards. Existing coverage did not distinguish either case: the "centre the whole wrapped line" test already passed with the cursor fixed at the line end, and the tall-line test accepted the cursor anywhere in the last two rows
  • 1 e2e test in test/specs/fold-providers.e2e.ts for #193 — folds # Introduction with zc and asserts the placeholder is exactly — 12 lines. Red first, at Received: "Introduction — 12 lines". It replaces heading fold placeholder contains heading text, which pinned the duplication this removes, and which additionally checked nothing whenever no placeholder rendered: its assertion sat inside if (placeholders.length > 0 && placeholders[0] !== '…'), the same vacuity already removed from the callout test beside it. In test/unit/fold/metadata.test.ts the four label() expectations moved to the new string, and the 😀 é case now reads the title from the metadata entry rather than through the placeholder — it is the only post-tree.delete() probe in a test named for exactly that, and through the label it would no longer be distinguishable from the regex fallback, which never touches the tree. Negative-controlled in two independent directions: restoring the title in both placeholder branches failed 7 unit tests ('Title — 1 lines', ' — 1 lines' and '😀 é — 1 lines' against '— 1 lines'), and dropping the extracted title failed the new probe at expected '' to be '😀 é' while leaving the placeholder assertions green
  • 13 e2e tests in test/specs/open-line-list.e2e.ts for #195, every expected value taken from a measured Obsidian-core Enter result on the same line rather than written by hand. Red first: 10 of the 13 failed, each at the same shape — expected > , received the empty string; > against ; > text against text. The remaining three had no before-state and were negative-controlled by sabotage instead. Disabling the fenced-code-block guard failed both fence tests, at > and > - where an empty line was expected, while leaving the other ten green, so the control is targeted rather than a blanket break. The third, an undo test carried over in the shape already used for lists, could not be made to fail: splitting the single replaceRange into two adjacent calls did not break it, because CodeMirror coalesces them into one undo unit, and it had passed before the fix as well. It was rewritten to assert the post-o document before undoing, which is the assertion red-first already proved falsifiable; the one-step-undo property itself remains unfalsified and is not claimed as covered
  • 2 e2e tests in test/specs/rpc-editor-options.e2e.ts for the indentation fix, asserting both directions because either alone passes under a hardcoded setting. Red first on the tab case, at " - X" against "\t- X", checked through the Neovim buffer and the CM6 mirror. The space case had no before-state and was negative-controlled by applying the naive fix — hardcoding expandtab=false failed it at "\t- X" against " - X" while leaving the tab case green, so the control is targeted. The suite's beforeEach now pins the vault's indent settings so the two cases cannot leak into each other or into the existing scenarios
  • The 31-shape comparison behind those expected values was measured three ways — Obsidian's Enter and the bundled engine's o/O in-product, and the Neovim backend's o/O through a live RPC connection — and is recorded in KNOWN_LIMITATIONS.md and docs/features/quality-of-life.md rather than committed as a spec
  • 5 e2e tests in test/specs/rpc-undo-isolation.e2e.ts, written red-first against the defect. All five failed on the unfixed build with the values recorded in rpc-undo-isolation-negative-controls.md — seq_last 4 against 0, and the note's body replaced by "" — and the disk assertion is what establishes severity rather than mere incorrectness, since the emptied buffer reached Target.md through the vault adapter. The headless reproduction that preceded them understates the defect and is documented as such: driving activateDocument()'s call sequence from a Lua script reports seq_last 1, because changes without an intervening u_sync() join one undo block, where real keystrokes reach 4
  • 1 e2e test in test/specs/rpc-popupmenu.e2e.ts comparing insert-completion placement against view.coordsAtPos() on a deliberately long proportional line, plus the existing insert case rewritten onto the same oracle. The previous assertion compared against expectedGridAnchor(), a spec-local helper that recomputed the production grid arithmetic, so it held for any anchor the implementation chose — the "expected value produced by calling the same code under test" shape — and it passed throughout the 806.9px error. The helper is deleted. Controls: 806.9px before the fix, 399.5px after anchoring but before the re-anchor, and Expected: 32, Received: 783.453 when the old arithmetic is restored under the new assertions
  • 5 e2e tests in test/specs/rpc-cursor-shape.e2e.ts covering block in normal and visual, the insert caret, the return to block, and the reset on disconnect. The reset is driven through disconnect rather than Escape because setExternalVimMode(null) is a real drivable path and covers the branch that would otherwise strand a bar cursor after the backend goes away. Insert mode asserts the absence of .cm-fat-cursor plus a non-transparent caret, because measureCursor() computes showCursor = !insertMode || overwrite || shape !== 'bar' and a bar shape therefore draws no element and reveals the native caret instead. Negative-controlled by cutting the forward in setExternalVimMode, which failed the insert and disconnect cases at Expected: not "rgba(0, 0, 0, 0)" while leaving the three block-cursor cases green
  • 18 e2e tests across test/specs/rpc-lsp-capability.e2e.ts and test/specs/rpc-native-capability.e2e.ts measuring how much of a native-LSP workflow already crosses the bridge, against an in-process LSP server registered in test/fixtures/nvim/lsp-probe.lua so the suite needs no language-server binary and no network. Server completion reaches the external popup menu, hover renders as a float, and diagnostic virtual_text and underline render as CM6 decorations carrying Neovim's own highlight groups. Every extmark case carries a same-namespace, same-position virt_text positive control, so no result can be satisfied by forwarding being broken outright — control 1 in rpc-capability-negative-controls.md demonstrates that guard firing
  • 8 e2e tests in test/specs/rpc-visual-selection.e2e.ts, red-first: all five original assertions failed at CM6 to receive a non-empty selection in visual mode. They assert the rendered selection text rather than coordinates — ['alp'] forward, ['pha'] backward, the whole line linewise, two lines across V j, and ['lp', 'el'] blockwise — so a correct range with a broken decoration still fails. Two of the eight exist to pin the design rather than the feature: keeps the CM6 selection a caret while visual mode is active and leaves visual mode when Escape is pressed both fail if the range is mirrored into EditorSelection, which is the implementation that breaks Escape. Forward and backward charwise are separate cases because the inclusive-to-exclusive conversion extends a different end in each direction, and a single-direction test passes with the extension on the wrong end. Control: disabling visualKind() leaves 1 passing, 7 failing, the survivor being the Escape case, which is pre-existing behaviour the spec protects
  • 3 e2e tests in test/specs/rpc-lsp-capability.e2e.ts for note switching, which moves the spec onto a two-file workspace. Red first: re-opens the LSP document when the mirrored note changes and attaches a late vim.lsp.enable() on the next note activation both failed before the fix. The probe server in test/fixtures/nvim/lsp-probe.lua now records didOpen/didClose URIs so the lifecycle can be asserted by name rather than inferred from attachment. keeps a client attached to the mirror after switching notes passes both before and after by design — it is there to catch the fix over-detaching
  • The three rpc-native-capability.e2e.ts cases that pinned virt_lines, sign_text and line_hl_group as not rendering are inverted to assert that they do, which is red-first against the previous behaviour. Each keeps its same-namespace virt_text positive control, so a pass still cannot come from forwarding being broken outright. The shared sign column's own coverage — marks-gutter, statuscolumn, the three gutter-* specs and marks-picker, 37 tests — was re-run to hold the multi-source change
  • 3 e2e tests in test/specs/rpc-quickfix-picker.e2e.ts seeding a real quickfix list over RPC and reading the rendered picker rows. Controls in rpc-quickfix-picker-negative-controls.md: an empty items() fails two of the three, and returning Neovim's absolute path instead of the vault-relative one fails only the path assertion, with the absolute paths shown. The empty-list case passes under the first sabotage by design, which is why it is not the only test
  • 6 unit tests in test/unit/picker/quickfix-path.test.ts covering the vault-relative conversion on both spellings of a macOS firmlinked path, a path outside the vault, a sibling directory sharing a prefix, and an empty filename. Platform-independent by construction: vaultRelative() was made pure and takes a base path, so the macOS case is reachable from a Linux run. Control: removing the /private normalisation fails exactly the two firmlink cases and leaves the other four green. The e2e assertion in rpc-lsp-capability.e2e.ts that compared the mirror buffer's name to the vault path was over-strict for the same reason and now compares resolved paths; a non-existent path still resolves to itself, so the relative-path defect it guards against still fails it
  • 3 unit tests in test/unit/ex-command-prefixes.test.ts checking every statically declared defineEx(name, prefix, …) in src/. Vim.defineEx throws when the short form is not a prefix of the name and nothing catches it, so the throw aborts the registration function and every ex command declared after the bad one is silently never registered. That shipped once during this work — defineEx('quickfix', 'qf', …) took :recent, :grep and :livegrep with it, surfacing as eleven failing picker tests that named none of them. Control: reintroducing the bad prefix fails the scan with workspace/commands.ts: 'qf' of 'quickfix'. A third assertion guards the scan itself, since a regex matching nothing would make the other two vacuous
  • 2 unit tests in test/unit/docs-stray-strikethrough.test.ts scanning every published Markdown file for bare tildes that pair into accidental strikethrough. GitHub-Flavoured Markdown accepts a single tilde as a delimiter, so prose writing an approximation as ~25 is harmless alone and renders struck-through the moment a second bare tilde lands on the same line. Eight lines were affected, including an Unreleased entry reading "failed on its ~25-row macOS and ~27-row Windows runners", which rendered "25-row macOS and " with a line through it. Control: unescaping that entry fails the scan with CHANGELOG.md:53. A second assertion guards the file list, since scanning nothing would make the check vacuous

Documentation

  • CHANGELOG.md
  • docs/features/quality-of-life.md: blockquote and quote-indented-list continuation added to the supported list, the fenced-code-block exclusion stated, a Neovim-backend callout recording the four measured divergences, and the parent-level insertion behaviour written down with its rationale
  • docs/reference/keybindings.md: the o / O quality-of-life row now names blockquotes
  • KNOWN_LIMITATIONS.md: the RPC list-continuation paragraph now records the three measured divergences that are inherent to comments — blockquoted list markers, checkboxes, and fenced-code exclusion — notes that blockquote continuation itself agrees, and records indentation style as a fourth projected setting with the measured reason the naive fix is wrong
  • AGENTS.md: new "Parity target: Neovim, except where Obsidian is the sensible choice" section stating the default and the two classes that qualify for deviating — Obsidian-flavoured Markdown editing conventions, and Obsidian-specific syntax — with the requirement to measure and record each such decision; document-sync.ts in the file tree now names the projected editor options
  • CONTRIBUTING.md: the same document-sync.ts description, kept in sync with AGENTS.md
  • docs/features/neovim-backend.md: the smart list continuation row now names blockquotes, the three things comments cannot express, and indentation style
  • docs/features/workspace-navigation.md: the fold placeholder sentence claimed the heading title was part of the label; replaced with the per-provider formats and why the heading case carries only a line count
  • docs/reference/keybindings.md: added the vertical scroll section (zz, z., zt, z<CR>, zb, z-), which was undocumented, with the measured wrapped-line and scrolloff behaviour
  • KNOWN_LIMITATIONS.md: recorded that the first zz/zt/zb press from a distant scroll can land up to two display rows off, because CodeMirror estimates coordinates for content it has not rendered
  • AGENTS.md: added setScrolloffSource to the fork capability list, with the two regimes it reproduces and why it is a callback rather than a defineOption value
  • Fork DIFFERENCES.md: new scrolloff in zz/zt/zb section carrying both measured Neovim tables, a new setScrolloffSource API section, and a cross-reference from the existing wrapped-lines section noting its measurements are all at scrolloff=0
  • docs/features/neovim-backend.md: the bridged list now names virt_lines, sign_text and line_hl_group, the visual selection and its decoration-not-selection rationale, and the per-activation LSP document lifecycle; the limitations list gains the quickfix picker and the two extmark fields that are still dropped
  • docs/features/ex-commands.md, docs/reference/keybindings.md: :quickfix added to the picker command tables, with no short form because Vim.defineEx requires one to be a prefix of the name
  • README.md, docs/features/index.md: picker built-in source count 14 to 15, naming the Neovim quickfix source
  • README.md, KNOWN_LIMITATIONS.md, AGENTS.md: re-certified latency figures, and the p50 gate described as a two-directional budget rather than a fork-faster ordering
  • KNOWN_LIMITATIONS.md: three entries struck through as fixed — per-mode cursor shape, vim.lsp.enable() attachment, and the extmark field set — plus a new entry recording the measured coupling behind multi-leaf mirroring and one recording that the visual selection is a decoration rather than an Obsidian selection
  • Fork DIFFERENCES.md: new setExternalCursorMode API section covering the three call sites that must agree and the two constraints established by measurement, and a correction to resetCursorState which no longer described everything it clears
  • CHANGELOG.md, KNOWN_LIMITATIONS.md, docs/features/animated-cursor.md: eight lines where two bare tildes on one line rendered the text between them struck through now escape them; the change is to the escaping only, and no wording or measurement was altered

[1.0.1] - 2026-09-23

Fixed

  • Which-key overlay no longer opens on a literal-argument leader key — r<leader>, and any other command awaiting a literal <character> argument (f, t, m, q, "), opened the leader overlay when the leader key was used as that argument. The fork signals vim-keypress only after it has consumed the argument, so the key was indistinguishable from a standalone leader press; the overlay now carries the previous key's expectLiteralNext state across the event and skips leader handling when the key was consumed as an argument. (#186)
    • Plugin: src/ui/which-key.ts, src/types/vim-api.d.ts
  • :obcommand no longer loses a charwise selection — a mapping such as vim.keymap.set("v", "<C-n>", ":obcommand templater-obsidian:create-new-note-from-template<CR>") ran the Obsidian command with no selection, so Templater and every other selection-dependent command saw nothing; the same command from the command palette, which never goes through the fork, saw it. The fork prefills '<,'> when : is pressed in visual mode, so the dispatcher received '<,'>obcommand … with selectionLine === selectionLineEnd for a selection inside one line — a case the #161 line-range restore skipped outright, while it widened a two-line charwise selection to both whole lines. The handler now rebuilds the range in document offsets from the '</'> marks and lastSelection, which survive exitVisualMode and carry columns, so charwise, linewise, and blockwise selections each restore as themselves. A typed numeric or % range keeps expanding to whole lines. (#192)
    • Plugin: src/workspace/commands.ts
  • Callouts no longer stay highlighted after a linewise yank — a callout the visual-line selection passed through kept a selection-coloured background forever, surviving the yank, further motions, and editing the block. Live Preview renders a callout the cursor is outside of as a block widget, which no CodeMirror mark decoration can reach, so linewise selection paints those widgets by toggling a class on their DOM element; the clearing loops skipped any element that was not isConnected. That is exactly the element that needs clearing — the moment the selection head enters the callout, Obsidian swaps the widget for source lines and caches the detached element, so the skipped class came back with the element on re-attach, and the element had already been dropped from the plugin's tracking set by then. Both clearing loops now remove the class unconditionally. (#190)
    • Plugin: src/vim/linewise-widget-highlight.ts
  • Yank highlight now covers rendered blocks — yG over a callout flashed every line around it and left the callout itself untouched, and the same held for embedded notes, images, and tables. A block that Live Preview renders is a CodeMirror block widget, and no mark decoration can reach one: ContentBuilder.point drops the active marks when it emits a block point. Those blocks are painted through their DOM element instead, on the same timer as the decorations, and a block is painted when the yank touches any line it stands in for — it is opaque, so there is no partial state to show. The painting is derived from the highlight state on every view update rather than applied once, because a linewise yank leaves the CodeMirror selection spanning the range, which makes Live Preview reveal the blocks it touches as source lines; they only return as widgets a transaction later, when the cursor collapses. The fade mode animates background-color rather than the decorations' opacity, which on a block would take its rendered content down with it. (#190)
    • Plugin: src/vim/yank-highlight.ts, src/vim/block-widgets.ts (new — shared with linewise-widget-highlight.ts)
    • Styles: styles.css
  • K on a wikilink now opens a page preview that stays open — the preview either did nothing at all or flashed up for well under a second before vanishing, which made keyword lookup unusable. The action synthesised a bare new MouseEvent('mouseover'), and Obsidian's page preview reads two things off that event. Every hover-link source can be configured to require Ctrl/Cmd, and a modifier-less event does not open the preview — it only arms a document listener that waits for a real Mod keydown, which is why K looked inert until Cmd was pressed afterwards. Separately, page preview records the event's client coordinates as the pointer position, and the popover then re-checks every 500ms that elementFromPoint() there still resolves inside its target element, hiding itself when it does not; an event at (0, 0) resolves to the top-left corner of the window, so the preview hid itself about a second after showing. The event now carries the Mod flag — a deliberate K press is the explicit request that modifier exists to confirm — and the cursor's own viewport coordinates from coordsAtPos, which also anchors the popover to the link rather than to the corner. (#188)
    • Plugin: src/workspace/navigation.ts

Tests

  • 5 e2e tests in test/specs/yank-highlight-widgets.e2e.ts for #190 — yG painting a callout and an embedded note, release when the highlight expires, a yank that stops short of the callout leaving it alone, and fade mode keeping the callout's computed opacity at 1. Each was negative-controlled against a deliberate break: dropping the coverage filter painted the untouched callout (0 → 1), a no-op unpaint left it lit past expiry (0 → 1), reusing the decorations' opacity fade dropped the callout to opacity 0.646399
  • 3 e2e tests in test/specs/linewise-widget-selection-issue190.e2e.ts for #190 — two drive the stale highlight through the transition that causes it (selection head enters the callout, widget detaches) and assert the re-attached widget is clean after a yank and after <Esc>; the third selects past the callout without ever entering it and asserts the widget is highlighted, because "nothing is highlighted" would otherwise pass for a build that highlights nothing at all. The suite waits on the widget actually rendering, so it cannot pass on an editor that never produced one. Each test strips the class on entry: Obsidian's widget cache outlives editor.setValue(), so without it the leak fails the following test instead of the one that caused it
  • 4 e2e tests in test/specs/obcommand-visual-mode.e2e.ts for #192 — two drive a real <C-n> mapping and read editor.getSelection() from inside the dispatched command (single-line and two-line charwise), one runs editor:toggle-bold over a selection straddling two words, one holds :1,2obcommand to whole lines while a charwise selection sits in the marks. The straddle is deliberate: the first version of that test selected a whole word and passed against the unfixed build, because editor:toggle-bold falls back to the word under the cursor when it sees no selection
  • 2 e2e tests in test/specs/keyword-lookup.e2e.ts for #188 — one forces every registered hover-link source to require Ctrl/Cmd, reproducing the reporter's configuration, and asserts K alone still opens the preview; the other asserts the preview is still on screen 2.5s later, past the 500ms watchdog and the 300ms hide timer it starts. Both assert the popover's text contains Target.md's body, so a popover from any other surface cannot satisfy them. The first version of the require-Ctrl/Cmd test omitted the configuration step and passed against the unfixed build, because the preview source does not require the modifier by default. Both then failed red (present false in each case), and each half of the fix was controlled in isolation against the finished code: dropping ctrlKey/metaKey failed only the first test, and pinning the coordinates back to (0, 0) failed only the second

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: extended the ex command path entry in the Obsidian command passthrough section with the charwise restore
  • CONTRIBUTING.md: added src/vim/block-widgets.ts to the codebase structure tree
  • docs/features/quality-of-life.md: recorded that yank highlight covers rendered blocks whole
  • docs/features/quality-of-life.md: recorded that K opens the page preview regardless of the Page preview core plugin's per-source Ctrl/Cmd requirement, and that the preview stays open

Full Changelog: 1.0.1...1.1.0

Don't miss a new motions release

NewReleases is sending notifications on new releases.