Added
- Smart list continuation now continues blockquotes and callouts —
o/Oon> quote, a> [!NOTE]title or body line, a nested> > quote, an indented> quote, or a spaceless>quotepreviously produced a bare empty line, because the marker group inLIST_CONTINUATION_REwas mandatory and a quote with no list marker never matched. Measured against Obsidian's own Enter key over 31 line shapes,odisagreed 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 carriesn:>incommentsand 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)
- Plugin:
- The second half of #195 —
oon 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 (oonly, 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 makeodisagree 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,
oon 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 setsexpandtabwithtabstop=4; nothing forwarded Obsidian's own indent settings, so the mirror always re-emitted spaces. Obsidian'suseTabandtabSizenow become the mirrored buffer'sexpandtab/tabstop/shiftwidth/softtabstop, which also governs>>,<<and==. Hardcodingexpandtab=falseis 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 tocommentsand stay documented. (#195)- Plugin:
src/rpc/document-sync.ts(NeovimEditorOptions.indent,APPLY_EDITOR_OPTIONS_LUA),src/main.ts(newvaultIndentStyle())
- Plugin:
zz,ztandzbnow honourscrolloff— they reached the scroll position throughscrollDOM.scrollTopwithout dispatching a CodeMirror transaction, and the plugin's margin enforcer is anupdateListenergated onupdate.selectionSet, so it never saw them;scrollToCursorhad noscrolloffterm of its own either. The margin was therefore ignored on every one of these commands, at the defaultscrolloffLinesof 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.ztandzbhold the margin past the cursor line on any line — Neovim movestoplinefrom 61 to 56 forztatso=5, where the plugin left 0 rows — and both stop at the centred position once the margin no longer fits, which is whyzt,zbandzzall reporttopline50 atso=11and never move again atso=12orso=9999. Inside a line taller than the window, where Neovim scrolls byskipcolrather than bytopline, the margin instead applies to the cursor's own display row:so=5givesskipcol400 andwinline18 against the plugin's 23, andso=9999givesskipcol880 and a centredwinline12.zzis untouched on a line that fits, becausezzonly ever sets a whole-linetopline; and the margin stays out of reach at a tall line's first and last display row, whereskipcolsaturates at 0 and atlineRows - winheight— which is why a cursor at the end of a very long wrapped line sits on the bottom row at everyscrolloff, exactly as reported in #183. Built-in vim mode is unaffected; it uses Obsidian's bundled codemirror-vim. (#183)- Fork:
src/vim.js(scrollToCursor, newVim.setScrolloffSource) - Plugin:
src/vim/scrolloff.ts(newgetScrolloffLines),src/vim/bundled-vim.ts
- Fork:
- A folded heading no longer repeats its own title — folding
# Introductionrendered 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
- Plugin:
- 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: pressinguon 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_lastwas 4 where a freshly activated note must report 0,ureplaced the note's two lines with"", and the emptied buffer reached disk through the vault adapter. The reseed now runs atundolevels = -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 latervim.go.undolevels = 500still reaches the buffer. The:help clear-undorecipe also worked but leftseq_lastat 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(newRESEED_BUFFER_LUA)
- Plugin:
- The Neovim insert-completion popup no longer renders far from the cursor — it was anchored at
column × defaultCharacterWidthand(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 atview.coordsAtPos(), the same choice the float bridge already makes forrelative = 'cursor'; grid cells remain only as the fallback for when CM6 cannot resolve a cursor rectangle. Anchoring alone left 399.5px, becausepopupmenu_showand 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 reproduced399.531byte-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(newreanchor()),src/rpc/document-sync.ts(newsetMirrorObserver()),src/rpc/neovim-connection.ts
- Plugin:
- 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 whilesetKeyInterceptActiveis 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 asetExternalCursorMode()override that feeds the cursor renderer alone. Two constraints on it are load-bearing and were established by measurement: it must not writecm.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 turnedrpc-ime.e2e.tsandrpc-lifecycle.e2e.tsfrom 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 noViewUpdate, the fork keeps a registry of liveBlockCursorPlugininstances and refreshes them throughrequestMeasure- Fork:
src/block-cursor.ts(newsetExternalCursorMode,getExternalCursorMode,effectiveVimState,applyCaretColor,refreshExternalMode),src/index.ts - Plugin:
src/vim/external-mode.ts
- Fork:
- 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_cursorcarries(buf, row, col)andsyncCursor()dispatchedselection: { anchor }, so CM6's selection was empty even while Neovim reported modev, 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 itsto, 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 extragetpos('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 intoEditorSelectionrenders 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 invacross<Esc>, and unaffected by clearing the DOM range, while disabling the mirroring restoredn- 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
- Plugin:
- 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 onFileTypeand skips a buffer whose'buftype'is already set, so once the first activation left the mirroracwriteno later activation could attach anything; and the rename sent nodidClose/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', restoringacwriteonce the content is in place. Measured against an in-process server: switching notes emitsdidClosefor the old path anddidOpenfor the new one, and avim.lsp.enable()registered mid-session attaches on the next activation, where a barefiletype detectstill does not — that is Neovim's own rule, and it is what thebuftypeclear works around- Plugin:
src/rpc/document-sync.ts(newPREPARE_ACTIVATION_LUA)
- Plugin:
- Diagnostic signs, virtual lines and whole-line highlighting now cross the Neovim bridge —
ForwardedExtmarkcarriedhl_group,virt_textandpriorityand nothing else, so three ordinary persistent extmark fields that the redraw-time forwarding already saw rendered as nothing.virt_linesnow becomes a CM6 block widget,line_hl_groupa line decoration, andsign_text/sign_hl_groupreach the sign gutter. That gutter had to become multi-source first:setSignsEffectreplaced 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 asignMarkers()accessor so consumers do not depend on the shape. This is what diagnostic signs, diagnosticvirtual_lines, 0.12 code lens and gitsigns are expressed in.number_hl_groupandconcealremain dropped- Plugin:
src/rpc/companion.lua,src/rpc/decorations.ts(NeovimVirtualLinesWidget,signEntries),src/vim/sign-column.ts(newsetRpcSignsEffect,signMarkers),src/vim/statuscolumn.ts,styles.css
- Plugin:
- 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. Aquickfixsource and aPicker: Quickfix listcommand 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/:copento the bundled engine, which still has no quickfix model of its own- Plugin:
src/picker/sources/quickfix.ts(new),src/main.ts
- Plugin:
- 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/varthrough 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 andonSelectsilently 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,vaultRelativenow pure and taking a base path)
- Plugin:
:quickfixopens the quickfix picker, matching the ex command every other picker source already had. It carries no short form:Vim.defineExrequires the short form to be a prefix of the name,qfis not one, and Vim spells this:copenrather than:qfanyway- Plugin:
src/workspace/commands.ts
- Plugin:
Tests
- 6 e2e tests in
test/specs/vim-builtin/z-commands.e2e.tsfor thescrollofffix, every expected value taken from a headless Neovim 0.12.5 run rather than written by hand. Three cover the tall-lineskipcolregime — 5 rows below the cursor atso=5, a centred cursor row atso=9999, and a line that fits the viewport staying put at bothso=0andso=9999— and three coverzt/zbon an ordinary line, including the convergence property thatzt,zbandzzproduce one identical position oncesoexceeds 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-existingzttests in the#143block were rewritten in the same units for the same reason:zt should place cursor line within top 15% of viewportasserted a flat 15% where the correct value is nowscrolloff / viewportRows, andzt should place cursor line near the top of viewport, not centerrequired thezt/zzgap to exceed 30% of the viewport, which the margin eats on a short window. Both encoded aztthat 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, at0.202against0.15and161against173; the first was renamed tozt should place cursor line at the scrolloff margin, not the centrebecause its old name named the threshold. Both now assert in display rows against the measured viewport and the configuredscrolloff, and both were negative-controlled by makingztreturnzz's position — the #143 defect itself — which failed them at22.10and21.32rows 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, andexpect(-0).toBe(0)failsObject.is, so the last-visible-row check now readsMath.abs(rows) < 0.5; and a hardcoded> 20rows described a 54-row viewport, where CI's is ~37. Both were reproduced locally by shrinking the editor to 35.9 rows —-0and 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-fixzt/zbposition cases failed all three of their tests (0 rows against 5, 0 rows against 5,blockTop0 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 (blockTop447 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.tsfor #183 — the follow-up comment reportszzcentring 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,scrolloff0,nosmoothscroll) and both are what Neovim does, so the tests pin them instead of changing them. The first runszzthree times on one 18-row line with the cursor at its first, middle, and last character and asserts an identicalscrollTop— Neovim reportstopline35 for all three, with onlywinlinemoving (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'swinline23 of 23 atskipcol80/560/2960 for lines of 24/30/60 rows. Negative-controlled by rewriting thecentercase of the fork'sscrollToCursorto centrecharCoords(cursor).bottom: the first failed withscrollTop2518 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.tsfor #193 — folds# Introductionwithzcand asserts the placeholder is exactly— 12 lines. Red first, atReceived: "Introduction — 12 lines". It replacesheading fold placeholder contains heading text, which pinned the duplication this removes, and which additionally checked nothing whenever no placeholder rendered: its assertion sat insideif (placeholders.length > 0 && placeholders[0] !== '…'), the same vacuity already removed from the callout test beside it. Intest/unit/fold/metadata.test.tsthe fourlabel()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 atexpected '' to be '😀 é'while leaving the placeholder assertions green - 13 e2e tests in
test/specs/open-line-list.e2e.tsfor #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;> textagainsttext. 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 singlereplaceRangeinto 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-odocument 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.tsfor 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 — hardcodingexpandtab=falsefailed it at"\t- X"against" - X"while leaving the tab case green, so the control is targeted. The suite'sbeforeEachnow 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/Oin-product, and the Neovim backend'so/Othrough a live RPC connection — and is recorded inKNOWN_LIMITATIONS.mdanddocs/features/quality-of-life.mdrather 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 inrpc-undo-isolation-negative-controls.md—seq_last4 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 reachedTarget.mdthrough the vault adapter. The headless reproduction that preceded them understates the defect and is documented as such: drivingactivateDocument()'s call sequence from a Lua script reportsseq_last1, because changes without an interveningu_sync()join one undo block, where real keystrokes reach 4 - 1 e2e test in
test/specs/rpc-popupmenu.e2e.tscomparing insert-completion placement againstview.coordsAtPos()on a deliberately long proportional line, plus the existing insert case rewritten onto the same oracle. The previous assertion compared againstexpectedGridAnchor(), 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, andExpected: 32, Received: 783.453when the old arithmetic is restored under the new assertions - 5 e2e tests in
test/specs/rpc-cursor-shape.e2e.tscovering 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 becausesetExternalVimMode(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-cursorplus a non-transparent caret, becausemeasureCursor()computesshowCursor = !insertMode || overwrite || shape !== 'bar'and a bar shape therefore draws no element and reveals the native caret instead. Negative-controlled by cutting the forward insetExternalVimMode, which failed the insert and disconnect cases atExpected: not "rgba(0, 0, 0, 0)"while leaving the three block-cursor cases green - 18 e2e tests across
test/specs/rpc-lsp-capability.e2e.tsandtest/specs/rpc-native-capability.e2e.tsmeasuring how much of a native-LSP workflow already crosses the bridge, against an in-process LSP server registered intest/fixtures/nvim/lsp-probe.luaso the suite needs no language-server binary and no network. Server completion reaches the external popup menu, hover renders as a float, and diagnosticvirtual_textandunderlinerender as CM6 decorations carrying Neovim's own highlight groups. Every extmark case carries a same-namespace, same-positionvirt_textpositive control, so no result can be satisfied by forwarding being broken outright — control 1 inrpc-capability-negative-controls.mddemonstrates that guard firing - 8 e2e tests in
test/specs/rpc-visual-selection.e2e.ts, red-first: all five original assertions failed atCM6 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 acrossV 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 activeandleaves visual mode when Escape is pressedboth fail if the range is mirrored intoEditorSelection, 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: disablingvisualKind()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.tsfor note switching, which moves the spec onto a two-file workspace. Red first:re-opens the LSP document when the mirrored note changesandattaches a late vim.lsp.enable() on the next note activationboth failed before the fix. The probe server intest/fixtures/nvim/lsp-probe.luanow recordsdidOpen/didCloseURIs so the lifecycle can be asserted by name rather than inferred from attachment.keeps a client attached to the mirror after switching notespasses both before and after by design — it is there to catch the fix over-detaching - The three
rpc-native-capability.e2e.tscases that pinnedvirt_lines,sign_textandline_hl_groupas not rendering are inverted to assert that they do, which is red-first against the previous behaviour. Each keeps its same-namespacevirt_textpositive control, so a pass still cannot come from forwarding being broken outright. The shared sign column's own coverage —marks-gutter,statuscolumn, the threegutter-*specs andmarks-picker, 37 tests — was re-run to hold the multi-source change - 3 e2e tests in
test/specs/rpc-quickfix-picker.e2e.tsseeding a real quickfix list over RPC and reading the rendered picker rows. Controls inrpc-quickfix-picker-negative-controls.md: an emptyitems()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.tscovering 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/privatenormalisation fails exactly the two firmlink cases and leaves the other four green. The e2e assertion inrpc-lsp-capability.e2e.tsthat 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.tschecking every statically declareddefineEx(name, prefix, …)insrc/.Vim.defineExthrows 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,:grepand:livegrepwith it, surfacing as eleven failing picker tests that named none of them. Control: reintroducing the bad prefix fails the scan withworkspace/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.tsscanning 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~25is 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 withCHANGELOG.md:53. A second assertion guards the file list, since scanning nothing would make the check vacuous
Documentation
CHANGELOG.mddocs/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 rationaledocs/reference/keybindings.md: theo/Oquality-of-life row now names blockquotesKNOWN_LIMITATIONS.md: the RPC list-continuation paragraph now records the three measured divergences that are inherent tocomments— 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 wrongAGENTS.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.tsin the file tree now names the projected editor optionsCONTRIBUTING.md: the samedocument-sync.tsdescription, kept in sync withAGENTS.mddocs/features/neovim-backend.md: the smart list continuation row now names blockquotes, the three thingscommentscannot express, and indentation styledocs/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 countdocs/reference/keybindings.md: added the vertical scroll section (zz,z.,zt,z<CR>,zb,z-), which was undocumented, with the measured wrapped-line andscrolloffbehaviourKNOWN_LIMITATIONS.md: recorded that the firstzz/zt/zbpress from a distant scroll can land up to two display rows off, because CodeMirror estimates coordinates for content it has not renderedAGENTS.md: addedsetScrolloffSourceto the fork capability list, with the two regimes it reproduces and why it is a callback rather than adefineOptionvalue- Fork
DIFFERENCES.md: newscrolloffinzz/zt/zbsection carrying both measured Neovim tables, a newsetScrolloffSourceAPI section, and a cross-reference from the existing wrapped-lines section noting its measurements are all atscrolloff=0 docs/features/neovim-backend.md: the bridged list now namesvirt_lines,sign_textandline_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 droppeddocs/features/ex-commands.md,docs/reference/keybindings.md::quickfixadded to the picker command tables, with no short form becauseVim.defineExrequires one to be a prefix of the nameREADME.md,docs/features/index.md: picker built-in source count 14 to 15, naming the Neovim quickfix sourceREADME.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 orderingKNOWN_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: newsetExternalCursorModeAPI section covering the three call sites that must agree and the two constraints established by measurement, and a correction toresetCursorStatewhich 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 signalsvim-keypressonly after it has consumed the argument, so the key was indistinguishable from a standalone leader press; the overlay now carries the previous key'sexpectLiteralNextstate 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
- Plugin:
:obcommandno longer loses a charwise selection — a mapping such asvim.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 …withselectionLine === selectionLineEndfor 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 andlastSelection, which surviveexitVisualModeand 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
- Plugin:
- 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
- Plugin:
- Yank highlight now covers rendered blocks —
yGover 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.pointdrops 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 animatesbackground-colorrather 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 withlinewise-widget-highlight.ts) - Styles:
styles.css
- Plugin:
Kon 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 barenew 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 whyKlooked 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 thatelementFromPoint()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 deliberateKpress is the explicit request that modifier exists to confirm — and the cursor's own viewport coordinates fromcoordsAtPos, which also anchors the popover to the link rather than to the corner. (#188)- Plugin:
src/workspace/navigation.ts
- Plugin:
Tests
- 5 e2e tests in
test/specs/yank-highlight-widgets.e2e.tsfor #190 —yGpainting 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 at1. 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 opacity0.646399 - 3 e2e tests in
test/specs/linewise-widget-selection-issue190.e2e.tsfor #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 outliveseditor.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.tsfor #192 — two drive a real<C-n>mapping and readeditor.getSelection()from inside the dispatched command (single-line and two-line charwise), one runseditor:toggle-boldover a selection straddling two words, one holds:1,2obcommandto 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, becauseeditor:toggle-boldfalls back to the word under the cursor when it sees no selection - 2 e2e tests in
test/specs/keyword-lookup.e2e.tsfor #188 — one forces every registered hover-link source to require Ctrl/Cmd, reproducing the reporter's configuration, and assertsKalone 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 containsTarget.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 thepreviewsource does not require the modifier by default. Both then failed red (presentfalse in each case), and each half of the fix was controlled in isolation against the finished code: droppingctrlKey/metaKeyfailed only the first test, and pinning the coordinates back to (0, 0) failed only the second
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: extended the ex command path entry in the Obsidian command passthrough section with the charwise restoreCONTRIBUTING.md: addedsrc/vim/block-widgets.tsto the codebase structure treedocs/features/quality-of-life.md: recorded that yank highlight covers rendered blocks wholedocs/features/quality-of-life.md: recorded thatKopens 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