See individual releases on GitHub for per-version discussion and asset downloads.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • Experimental tableWidgetMode: owned, a table renderer the plugin owns outright — currently DISPLAY ONLY. A highest-precedence block-replace decoration supplied from a state field removes Obsidian’s own table widget from the DOM entirely (measured: our root renders, .cm-table-widget count is zero, and no .cm-line contains a pipe), and the rendered rows match the table source exactly. Editing inside the table is not supported yet and native remains the default: a block-replaced range cannot host a caret, so the selection relocates, vertical motion treats the table as one display line, and insert-mode typing lands outside it. The renderer exists so the editing surface can be built on a foundation that is already measured, and it is gated to desktop, Live Preview, the bundled engine and a disconnected Neovim backend, each blocker falling back to native with a one-time notice. Table detection for rendering is deliberately stricter than the existing scanner — it requires a separator row and excludes fenced code blocks and frontmatter, so two | foo lines inside a fence are not replaced by a fake table.

  • The owned table surface now scrolls horizontally to follow the caret, closing #167 item 5 for it. The nested editor’s scroller is overflow-x: auto and genuinely scrollable, but the parent’s vim owns the motion so the child never receives a cursor command of its own and nothing moved it: measured at scrollLeft: 0 through 60 l presses while native reached 623, which made owned strictly worse. One scrollIntoView on the mirrored selection dispatch fixes it. scrolloff=100 still disables horizontal scrolling, in native as well as owned — item 6 is pre-existing and reproduced rather than introduced.

    • Plugin: src/vim/table/nested-view.ts
  • Moving out of an owned table hands control back to the parent editor. Unmounting the nested editor destroys its DOM and with it the only focused element, so before this the cursor stopped moving after the first motion out of a table — the editor looked dead rather than broken, with document.activeElement on <body>. Focus is now handed back, but only when the nested editor actually held it: handOff runs whenever the cursor leaves a table, including when the user’s attention has moved to a sidebar or another pane, and an unconditional focus call would drag it back into the editor. Re-entering remounts and refocuses. Two of the step’s three stated concerns needed no code, measured rather than assumed: the desired column survives a blank line in both directions because the parent owns the motion and the fork’s lastHPos applies directly, and 3j from the second-to-last row consumes the whole count outside the table in one dispatch, so no remainder ever crosses the boundary.

    • Plugin: src/vim/table/nested-view.ts
  • A visual selection inside an owned table is now visible. The parent’s visual range is mirrored into the nested editor, which is the focused view and therefore the one whose selection renders; previously the user was in visual mode with nothing highlighted, close to the “loses state, status bar still says V-Line” symptom in #167. Two sources are needed and the difference was measured: charwise visual leaves the parent’s own CM6 selection as the real range (anchor: 48, head: 49 after v, head: 51 after vll), while linewise collapses it to the line start and keeps the range in vim.sel’s line numbers, so the latter is expanded to whole lines. A control that routes linewise through the charwise path fails only the linewise scenario, which is what shows the two are not interchangeable. This also corrects the exploration’s earlier record, which reported the parent’s selection as empty throughout visual mode — that reading was taken after V, the one case that collapses it. The nested editor gained drawSelection(): constructed directly, it has only the extensions it is given, and without that one a selection is left to the browser’s native highlight and renders no .cm-selectionBackground at all. Visual block renders as the enclosing charwise span, because the mirror forwards only the primary range — the fork already emits one range per row and CodeMirror supports multiple ranges, so that is a gap in the mirror rather than a limit of either, and mirroring them all is planned.

    • Plugin: src/vim/table/selection-mirror.ts (new), src/vim/table/nested-view.ts, src/types/vim-api.d.ts (VimState.sel)
  • Insert mode now works inside an owned table, with dot-repeat replaying exactly one character. Insert-mode characters cannot be routed — the fork leaves them to CodeMirror’s native input path — so they are left native to the nested editor and reach the parent as minimal single-region diffs, trimmed to the common prefix and suffix. That minimality is the whole point: forwarding the table as one region-replacing change makes the parent’s vim record the entire synced span in lastInsertModeChanges, and . replays it, which is how a single Q produced | Qaa Qaa | in the original measurement. The forward is suppressed while the nested editor is composing, so an IME’s preedit rewrites never enter the real document and one composition costs one undo step: measured with CDP-driven composition, the document is unchanged during preedit, the commit lands once with no residue, a single u reverses the whole composition, and a cancelled composition leaves the document byte-identical. <Esc> and modifier combinations are still routed while the rest stays native, because leaving <Esc> native is a measured trap — the parent never leaves insert mode and the following u and . are typed in as literal text, which reads as plausible output rather than an error.

    • Plugin: src/vim/table/sync-up.ts (new), src/vim/table/key-router.ts, src/vim/table/nested-view.ts
  • Normal and visual mode now work inside an owned table — issue #167 item 3. The nested editor takes focus and a key router sends every keystroke to the parent’s vim, so one vim state owns commands, mode, registers, undo and dot-repeat while the parent keeps the real document. Measured through real key events: dd removes a row and types no literal dd, u restores it, . repeats it, l moves the parent’s head by one without touching the document, j moves one table row and stays inside the table, V reports visual mode and Vjd removes two rows with the header and surrounding text intact. j moving a row rather than jumping past the whole widget confirms that the collapse-to-one-visual-line behaviour was an artefact of a focused parent; delegating focus retires it. The child’s caret is kept at parentHead - tableFrom — the child’s document is exactly the parent’s slice, so the translation is a subtraction — because a caret that drifts from the head makes every command act somewhere other than where the cursor appears. Insert-mode characters are not routable — handleKey returns undefined for a printable character and inserts nothing, focused or blurred — so they are left native and forwarded as diffs instead; see the insert-mode entry above.

    • Plugin: src/vim/table/key-router.ts (new), src/vim/table/nested-view.ts
  • A nested editor lifecycle for tableWidgetMode: owned, mounted but not yet wired. One EditorView per parent editor, mounted into the active table’s widget when the cursor is inside it, re-seeded in place rather than remounted when the table’s text changes, and torn down through runCleanups when the cursor leaves. A nested editor is what can make a block-replaced range editable at all — the replaced range has no caret and no position mapping — and it is constructed directly rather than through Obsidian, so none of the per-view subsystems reach it: measured at zero gutters and zero cursor layers inside it while the parent editor retains both. It is mounted read-only and unfocused on purpose, because focusing an unrouted editor would swallow every keystroke as plain text in a document the parent never sees, which is issue #167 item 3 reproduced in new code. Routing keys to the parent’s vim and forwarding insert-mode text remain the next steps, and owned remains display-only and opt-in.

    • Plugin: src/vim/table/nested-view.ts (new), src/vim/table/surface-widget.ts (rows moved into their own container), src/main.ts, styles.css
  • Owned mode now clears Obsidian’s own table cell editor, which was silently locking the cursor’s cell against writes. Owning the table renderer does not take over the table editor: with .cm-table-widget measurably gone from the document, parking the cursor in a table still constructs a TableCellEditor, and while editMode.tableCell holds it a change dispatched to that one cell’s range does not apply — writes to the header row, the separator row and the rest of the document all do, so it is a per-cell lock rather than anything range-wide. Obsidian builds the whole thing off-document (measured at cm.dom.isConnected === false, inside a detached widget, with focus still on the parent’s .cm-content), which is why it is invisible, why it does not contest focus, and why nothing noticed it until a write was attempted. The reference is what gates the lock, not the editor’s liveness: after destroy() it remains set and the write is still blocked, and clearing editMode.tableCell is what restores writability. null is Obsidian’s own resting value for that field, so this leaves the view in a state it already handles everywhere; destroy() still runs first, to release the editor’s resources. A cleared editor does not return on its own but does return when the selection moves inside a table, so the clear runs from the same reconcile pass as the nested editor. The lock is also not unconditional — a measured run had Obsidian opening no cell editor at all, with the write landing unassisted — so the regression test asserts the suppressor actually fired rather than only that the write succeeded.

    • Plugin: src/vim/table/native-cell-suppressor.ts (new), src/vim/table/nested-view.ts, src/main.ts
  • Snippets work inside the owned table surface. A prefix expands on Tab, Tab/Shift+Tab move between tabstops, Escape ends the session and leaves insert mode in one press, and the active tabstop is selected in the cell. The plan behind this recorded that expansion already worked and only navigation was broken; that was carried from a spike against code that has since changed and is false — measured on the shipped build, wla<Tab> in a cell left the literal prefix | wlaaa | 11 | and opened no session at all, because the router leaves an insert-mode Tab native to a nested editor that carries no snippet extension, while the expansion keymap, the session field and the tabstop bindings all live on the unfocused parent. Tab is now driven against the parent explicitly, navigation before expansion, matching the precedence the parent’s own keymaps already define. Scope is owned with the bundled engine: native is unchanged, because Obsidian’s per-cell editor owns the keys there, and the Neovim backend is out. One gap is measured and documented rather than fixed: a body containing a literal | or a newline damages the row. A second — a repeated tabstop updating only the occurrence being edited — was pinned with an explicit flip instruction and then closed by the block-selection mirror below, since CodeMirror represents linked tabstops as multiple selection ranges.

    • Plugin: src/vim/table/key-router.ts, src/vim/table/nested-view.ts, src/vim/table/selection-mirror.ts, src/snippets/tab-expand.ts, src/snippets/autocomplete-types.ts, src/main.ts
  • The active snippet tabstop is visible inside a table cell. mirrorRange collapses to a caret whenever vim is not in visual mode, and a tabstop is a non-empty selection in insert mode, so the child’s selection was measured empty at every field while the parent’s read page — the feature was fully working and the user could not see which field they were on. The mirror now keeps a non-empty range when a session is open, opt-in rather than unconditional because normal mode also carries a non-empty CM6 selection for the block cursor and drawing that would be wrong.

  • Visual block renders as a true rectangular selection in the owned table surface. The parent carries one CM6 range per row — <C-v> over three rows yields three ranges holding exactly the cell texts — and the mirror kept only .main, so a three-row block displayed as a one-row selection and misreported what the operator would act on. Every range is now mirrored, with mainIndex preserved so scrollIntoView follows the vim head, and the nested editor gains EditorState.allowMultipleSelections: without that facet CodeMirror silently keeps only the first range however many are dispatched, so the mapping and the permission are separately load-bearing. Single-range selections still delegate to the existing path, leaving charwise, linewise and caret behaviour untouched. Keyboard multi-cell selection is therefore supported; the mouse equivalent remains a documented regression.

    • Plugin: src/vim/table/selection-mirror.ts (new mirrorRanges), src/vim/table/nested-view.ts, src/main.ts
  • The owned table surface renders in a monospace grid. Obsidian’s editor font is proportional, under which a column-aligned table’s boundaries do not line up between rows at all — and the alignment is the owned surface’s 2D model, so the grid is load-bearing rather than cosmetic. var(--font-monospace) is applied to the passive rows and the nested editor from one shared block, with ligatures disabled (a ligature collapses two characters into one advance) and wrapping off (a wrapped row would put one table row on two display lines, which the model cannot represent). The two renderers must keep sharing that block: they show the same table alternately, so any difference between them is a visible jump at the moment the cursor enters the table, now pinned to within 1px.

    • Plugin: styles.css
  • The owned table surface derives one layout model from its source, and decorates cells and delimiters from it. src/vim/table/layout-model.ts is a pure function of text — per-cell absolute ranges, trimmed content ranges, row and column identity, per-column alignment from the separator row, column widths, and delimiter offsets — so the decoration layer and the passive renderer share one geometry instead of each re-deriving it. It never throws, because it backs a StateField where an exception takes the editor down and table source is user input that is malformed more often than not: a row with fewer cells than the header yields fewer cells, a row with more is truncated for layout with the excess counted, and in both cases the document stays byte-identical, because rendering must not edit a document. Widths are measured in UTF-16 units, the same model realignTableLines was measured to share with Obsidian. Cell and delimiter marks carry the column’s alignment as a class; they deliberately avoid .cm-table-widget, which is surface-gate.ts’s TABLE_CELL_SELECTOR.

    • Plugin: src/vim/table/layout-model.ts (new), src/vim/table/cell-decorations.ts (new), src/vim/table/nested-view.ts
  • Idle tables in the owned surface render as a marked-up grid rather than plain text lines. Each row is painted as cell and delimiter spans carrying the same classes the cursor’s editor uses, so a theme styles one surface and gets both, and each column’s alignment — parsed from the separator row — rides along as a class. The rendering is character-identical to the source: every character is emitted in order and nothing is inserted, hidden or re-ordered. That is not a stylistic choice. The active renderer is CodeMirror text showing the padded source, so a passive renderer free to lay out cells independently would shift the grid at the instant the cursor entered the table, which is the class of complaint #167 is about. Neither surface is an HTML <table> and neither re-aligns a cell’s text, for that reason.

    • Plugin: src/vim/table/surface-widget.ts
  • tableWidgetMode: owned now works while the Neovim backend is connected, as a presentational table. The nested cell editor is contenteditable=false and never takes focus, so Neovim keeps owning text, keys, IME composition, floats and the command line — key-delegation.ts already owns keys from the parent’s contentDOM by enclosure, and the child is made non-competing by construction rather than by relying on an event order that is accidental. No key router, no document sync and no cursor push are added on this path. Two costs are documented in the setting’s own description, in both settings implementations, because a notice only fires when a blocker exists and there no longer is one: Neovim’s own rendering (extmarks, flash labels, diagnostics, folds, signs) does not appear inside the table, and the active cell is not highlighted.

    • Plugin: src/vim/table/gates.ts, src/vim/table/nested-view.ts, src/settings.ts (both implementations)

Changed

  • tableWidgetMode: raw is deprecated, with a one-time notice, ahead of removal in a later release. It does not do what its name implies — measured, it hides Obsidian’s widget with CSS while Obsidian still block-replaces the table’s range, so the table renders as nothing, which is why Source mode is the recommended replacement. The value keeps working: it is a documented vimrc and Lua option, and a deprecation that breaks configs is not a deprecation. The legacy shapes that used to resolve to it now retarget to native instead — suppressTableWidget: true and tablewidget=always, in both the stored-settings migration and the separate vimrc/Lua option mapping — while an explicitly chosen raw is deliberately left alone. docs/configuration/vimrc.md and docs/configuration/lua-config.md also omitted owned entirely and now list all three values. The table-widget migration moved out of main.ts into src/settings-migration.ts, where its siblings already live, so it is unit-testable.

    • Plugin: src/settings-migration.ts (new migrateTableWidgetMode), src/main.ts, src/settings.ts (both implementations), src/vim/options.ts
  • skipInTableCells() is deleted — it never skipped anything. It returned the extension unchanged plus a CSS class referenced nowhere in styles.css, so all 14 of its call sites in src/main.ts read as keeping a subsystem out of Obsidian’s per-cell table editors and none of them did. Measured, of those 14: one does per-view work a gate can reach — the scrolloff enforcer’s EditorView.updateListener, which ran 8 times inside a cell across two keystrokes, calling coordsAtPos and getBoundingClientRect each time — and it is now gated on classifySurface. Four are facet-based gutter({...}) extensions with no per-view condition, suppressed only by the existing .cm-table-widget .cm-gutters rule, which now carries a comment saying so. Four are StateFields or fold-service facets that receive an EditorState and never see a view. One is a properties MutationObserver that is inert in a cell by construction, so gating it would be unobservable and therefore unverifiable. The scrolloff gate needs no connected-view latch, unlike the treesitter bridge’s: an updateListener has no construction phase, and its selectionSet guard already implies the view is in the document.

    • Plugin: src/vim/scrolloff.ts, src/main.ts (14 wrappers removed), src/util/cell-editor-guard.ts (deleted), styles.css (comment)
  • native remains the default table mode, deliberately. The plan for this release sequence set an explicit acceptance bar for making owned the default, and it is not met: the owned surface renders the table’s source in a monospace character grid rather than as a formatted table, the nav overlay’s single-key structural commands (o, O, dd, J, K, H, L, I, A) remain native-only, the Neovim backend cannot show which cell is active, and the cross-platform suite has not been run against a flipped default. The bar was stated before the work started precisely so this would be a reading rather than a judgement call, and three of its seven gates are unmet. owned stays opt-in; native is unchanged and is not deprecated.

Fixed

  • Escape could not cancel a pending Neovim command while a table cell editor was open, stalling the RPC pipeline with no way out. TableNavController.installCellEscapeCapture registered a document-capture keydown handler calling stopImmediatePropagation(), which destroyed the event before the key-delegation listener on the editor could forward <Esc> to nvim_input. Any Neovim command awaiting its second character — after Z, d, g, ", a count or an operator — therefore could not be cancelled, and RPC API requests stalled indefinitely. Measured: the identical Z then Escape sequence recovers in 3 ms with no cell editor open and never recovers with one, across two Escapes. The capture now skips stopImmediatePropagation() while a backend other than the bundled fork owns keys, resolved through isExternalBackendActive(), which the connection lifecycle already maintains. Behaviour with RPC disconnected is unchanged, so the documented two-step cell-edit(insert) → cell-edit(normal) → table-nav Escape transition still holds for the bundled engine. The underlying stall is normal Neovim behaviour awaiting a command’s completion and is not table-specific — it reproduces with no table involved — so it is not treated as a defect.

  • The treesitter bridge allocated a WASM parser and retained a syntax tree for every table cell editor Obsidian opens. Obsidian’s Live Preview table editor creates a separate CM6 EditorView per cell, so every extension registered through registerEditorExtension() is instantiated again for a one-line cell document — and the bridge is the most expensive of them. Measured against a real Obsidian 1.13.7: with the gate removed, a cell editor holding aa reports a published tree; with it in place it reports none, while the parent editor keeps its own. Two earlier attempts tried to keep the extension out of the view’s configuration with StateEffect.appendConfig dispatched from a ViewPlugin and installed it nowhere — main editors included — passing their cell-side assertions for exactly that reason; a parent control is now mandatory in any test of this. The branch also cannot be taken where it looks like it belongs. A cell editor is constructed detached (measured at isConnected: false, no .cm-table-widget ancestor, document "aa"), so a surface classified in the plugin’s create function reads document for every cell and gates nothing; the decision and the parse are deferred to the first update on a connected view, which is the mechanism table-cell-cursor-guard.ts already relies on. The latch fails in the safe direction — a surface it cannot classify keeps its bridge rather than silently losing one. The four gutters are not covered: they are built with CodeMirror’s facet-based gutter() API, which has no per-view condition, and are hidden with CSS in cells rather than excluded.

    • Plugin: src/util/surface-gate.ts (new classifySurface), src/treesitter/bridge.ts (GatedTreeSitterBridge), src/main.ts (getTreesitterSurfaceReport())
  • An unmatched Tab no longer risks writing into a table row. The first implementation of the above delivered the raw key to the parent’s whole keymap stack via runScopeHandlers, which passed every snippet assertion and still corrupted the table: with no matching prefix, Obsidian’s indent handler claimed the Tab and wrote a literal tab before the row (\t| iznlaa | 11 |). Only snippet commands run now, and an unclaimed Tab is left alone. The guard is a regression test asserting the full document, the absence of \t, and the row’s column count.

  • A realign is refused while the cursor is in insert mode. Realignment replaces the whole table, and three facts make that dangerous rather than untidy mid-insert: the fork’s adapter exposes changed regions with no meaningful origin, insert recording accepts origin === undefined, and a table-spanning replacement is therefore eligible to enter lastInsertModeChanges — after which . replays a formatter edit, the defect Plan B Step 5 fixed for sync-up. Both realign paths now share one guard.

    • Plugin: src/vim/table-operations.ts, src/motions/tables.ts
  • :tablerealign inside an owned table left the editor unresponsive. Writing the parent through the vim adapter focuses it as a side effect, which left the nested cell editor mounted but unfocused with the parent focused — measured mounted: 1, focused: false, parentFocused: true. The table was rendered by the child and driven by neither, so u and . did nothing at all: three successive undos left the document byte-identical. While the cursor is in a table the child owns focus, and that is now reclaimed on the kept-mounted path rather than only taken at mount, conditional on the parent actually holding focus so focus is never pulled from another pane or a modal. Undo granularity and dot-repeat both became correct as a consequence.

    • Plugin: src/vim/table/nested-view.ts
  • Clicking a cell in the owned table surface now moves the cursor there. The click moved the nested editor’s caret and left the parent’s where it was — measured, childHead 34 → 52 with parentHead stuck at 46 — so the caret the user saw and the position the next motion or operator acted on were different cells. A selection the child originates is now carried back up to the parent, and a click on a rendered delimiter snaps into a cell rather than leaving the cursor between two of them, since cellAt returns null there rather than guessing a side. Of the three interactions this step covers, the other two already worked: a right-click opens a context menu, and a block y over a 2×2 region already filled the register blockwise with the right text.

    • Plugin: src/vim/table/nested-view.ts
  • Ten table commands did nothing in tableWidgetMode: owned, silently. :tablerowafter, :tablerowbefore, :tablerowdelete, :tablerowup, :tablerowdown, :tablecolbefore, :tablecolafter, :tablecolleft, :tablecolright and :tablecoldelete — and their <leader>t bindings — wrap Obsidian command ids that drive its private TableEditor through the table widget, which owned removes. Measured with the cursor in a table, all ten returned the document byte-identical in owned while changing it correctly in native; :tablerealign was the only one that worked in both, because it alone was already text-based. Each now falls back to a text implementation when the widget is absent, so the same commands, action names and leader bindings work in both modes. The fallback keys on the widget’s absence rather than on the setting, because a per-view gate such as Source mode can leave the widget in place while the setting says owned, and there the native command is the right one. Two behaviours deliberately differ from Obsidian’s: a row will not move across the separator, which Obsidian allows and which silently changes which row is the heading, and the last remaining column cannot be deleted.

    • Plugin: src/vim/table/structural.ts (new), src/motions/tables.ts
  • The cursorline could tint a line inside Obsidian’s table cell editor, and the claim that it could not was wrong. An earlier entry in this release stated the defect “does not exist”, on a local measurement of zero .vim-motions-cursorline elements inside .cm-table-widget in all three cursorlineopt modes. CI measured one, on Linux, macOS and Windows alike, so whether the decoration applies is environment-dependent and the measurement should never have been generalised. The line and both modes are a StateField, which receives an EditorState and never sees a view, so classifySurface cannot withhold them the way it withholds the scrolloff listener; the fix neutralises the background instead. display: none remains wrong for the reason already recorded — buildCursorlineDecorations puts the class on the real line via Decoration.line, so hiding the element hides the cell’s text.

    • Plugin: styles.css
  • owned no longer reproduces #167 item 6. With scrolloff=100, horizontal scrolling inside a table is zeroed in native — a pre-existing defect owned used to share. The monospace grid sets white-space: pre, so the nested editor does not wrap and still scrolls: measured owned: 67 against native: 0 on a 1274px window. On a wide window the table fits and both read 0, which is why it surfaced only on the narrower CI runners.

  • The table cursor guard was keyed on Obsidian’s widget, so it never engaged in owned mode. mainEditorTableCursorGuard suppresses the parent’s vim cursor while the caret is inside a table, behind a precondition that queried .cm-table-widget — which tableWidgetMode: 'owned' removes by construction, measured widgets: 0. The predicate now asks whether a table is rendered by either implementation. This is the fourth time this defect has been fixed (#127, #135, #136, #132), and each time it re-entered through a condition keyed on the old implementation’s DOM rather than on the question being asked. The fix is now verified, by instrumenting the fork’s cursor plugin rather than querying the DOM: with the caret in a table, owned reports suppressed=true consistently where the old predicate was only intermittently true. That intermittency is itself the explanation for the historical flakiness — Obsidian’s cell editor and its .cm-table-widget exist transiently in owned before suppressNativeCellEditor clears them, so the old predicate flipped between frames and the cursor came and went. The mechanism was in this plugin’s predicate, not in the fork.

    • Plugin: src/vim/table-cell-cursor-guard.ts
  • The vim fork was hiding the caret inside an owned table cell, across an editor boundary. BlockCursorPlugin.update() sets display: none on every .cm-cursorLayer:not(.cm-vimCursorLayer) it finds in its own scrollDOM — right for its own view, wrong for the nested cell editor, which is a separate EditorView mounted inside that scrollDOM and draws its caret with plain drawSelection(). Measured: the nested layer held one caret, was focused, and was inline display: none, which is also why every probe of that caret reported 0x0 — a hidden element has no box. The fork now skips layers whose closest('.cm-editor') is not its own view.dom, in both update() and destroy(); the guard is generic and mentions no table. Verified against a local fork build (display: block, a 1x19 caret inside the cell’s box) with a negative control that restores display: none and 0x0, and the fork’s own suite green across four partitions at 1622 passing. Shipped in @saberzero1/codemirror-vim@6.4.4, which this release consumes; the regression spec runs unskipped.

    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (ownsLayer guard in update() and destroy(), documented in the fork’s DIFFERENCES.md)
    • Plugin: package.json (alias bumped to ^6.4.4)
  • The caret restored in an owned table cell is Obsidian’s native one, not the plugin’s. Recording this against the fix above rather than leaving it implied: unhiding the nested editor’s cursor layer restores drawSelection()’s caret, which is a 1px bar in every mode — measured 1x19 in normal, insert and back again, where the parent’s vim cursor is 9x19 in normal. So a cell shows a caret but not a vim caret, and cursorShapes does not apply there. The 1x19 was in the 6.4.4 evidence and should have been read as the wrong shape at the time. A fork API to draw the fork’s cursor in the nested view while reading mode from the parent’s adapter was prototyped and not shipped: it produced a block cursor but the shape did not follow mode, the native caret was not suppressed so two carets rendered, and the plugin’s CmAdapter type does not satisfy the fork’s CodeMirror.

Tests

  • Six scenarios for the raw deprecation plus nine unit cases for the migration, with six negative controls in test/specs/table-raw-deprecation-negative-controls.md. Two of those controls did not fire at first and each exposed a different hole: the once-per-session scenario used document edits as its stimulus, but the notice path runs on a settings reload, so the flag was never exercised; and nothing covered the vimrc/Lua option path at all, so the always mapping change in src/vim/options.ts was unverified until two handleEx scenarios were added. Controls 3 and 4 are deliberately asymmetric — one breaks the legacy cases and leaves the explicit raw passing, the other does the reverse — because retargeting a legacy value and migrating a user’s explicit choice are separate decisions.

  • Two specs for the cell-editor guard, plus six negative controls in test/specs/table-cell-guard-negative-controls.md. The scrolloff spec asserts a count rather than geometry, because a one-line cell editor has scrollHeight ≈ clientHeight and the enforcer’s own >= 1 threshold means it never writes scrollTop there — “it did not scroll” holds whether or not the listener ran. Its parent half is mandatory: a cell count of zero is identical whether the gate withheld the listener or it never installed anywhere, which is precisely how two earlier surface-gate attempts passed while doing nothing. The cursorline spec pins an absence, so its control inverts the assertion, and each scenario separately proves the selector matches something by asserting the parent’s highlight with the cursor outside a table.

  • One control is recorded as not applicable rather than given a passing substitute: the “classification moved into a ViewPlugin create function” sabotage has nothing to act on when the gate lives in an updateListener, which has no construction phase.

  • Six boundary hand-off scenarios (test/specs/table-boundary.e2e.ts), each pressing a second key after leaving the table and asserting its effect, because the first motion out lands correctly whether or not focus was handed back — asserting where it landed proves nothing. Two controls in test/specs/table-boundary-negative-controls.md fail disjoint sets, which is what shows the hand-off and its guard are separately load-bearing. Recorded there too: 3j from the second-to-last row passes even with no hand-off at all, because the whole counted motion is dispatched while the nested editor still holds focus, so that scenario cannot detect the defect it sits beside.

  • Two selection-rendering scenarios and eleven mirror unit tests, asserting the selected text read from the child’s own state rather than rectangle counts, because a count cannot tell a correct mirror from one highlighting the wrong rows: the whole aa row and not bb after V, both data rows and not the header after Vj, exactly three characters after vll, empty after <Esc>. Paired with the pre-existing Vjd row-deletion scenario, so a mirror that renders one range while the operator acts on another is caught from either side. Three controls in test/specs/table-selection-mirror-negative-controls.md, including one for drawSelection() — its absence looks exactly like a broken mirror, which is how the first implementation of this presented.

  • Three CDP-driven composition scenarios (test/specs/table-ime.e2e.ts) plus three insert-mode scenarios in the router spec, and eleven unit tests pinning the diff’s minimality and an exact round-trip (test/unit/table/sync-up.test.ts). Controls in test/specs/table-ime-negative-controls.md. One of them did not fire and led to deleting the compositionend flush the plan specified: by the time CodeMirror dispatches the committing transaction view.composing is already false, so the commit arrives at the ordinary update listener and the separate flush had nothing to do. Code whose absence no test can detect is unverified, so it was removed rather than kept as a safety net. Three further expectations in the unit tests were wrong rather than the code — prefix-first diffing lands at the end of a run of identical characters, and the diff region can split a surrogate pair, which is harmless because CodeMirror offsets are code units too.

  • Six scenarios for the table key router (test/specs/table-key-router.e2e.ts), each asserting on document text, vim mode or the parent’s head, with the router’s own counter used only as a precondition — .ast-grep/rules/solo-self-reported-success.yml exists for the other shape. A shared focus precondition runs in every scenario, and a negative control shows why: without focus the keys reach the parent directly and several scenarios produce the same document outcome, so the routing assertions alone cannot distinguish a working router from no router. Three negative controls in test/specs/table-key-router-negative-controls.md; the caret one did not fire at first because childHead >= 0 is satisfied by a fresh EditorView’s default selection of 0, and was replaced with the exact translation.

  • Six scenarios for the nested table editor’s lifecycle (test/specs/table-nested-view.e2e.ts), each pairing a nested-editor assertion with a parent one, plus a characterisation of Obsidian’s cell editor whose load-bearing half is the header-row control — without it, “the write did not land” passes when dispatching is broken for any reason at all. Six negative controls in test/specs/table-nested-view-negative-controls.md. One of them did not fire on the first attempt and exposed a defect on both sides: the spec was asserting on mounted, doc and the layer counts, all of which survive the editor being torn out of the document, so it was measuring an orphaned editor; the product, in turn, kept such an editor rather than remounting it. The spec now asserts DOM connectedness and that no row renders outside the rows container, and reconcile requires the view’s own DOM to still be connected.

  • A real-Obsidian gate measurement for the treesitter surface branch (test/specs/table-surface-gate.e2e.ts), asserting in one run that a live table cell editor has no syntax tree, that it is classified table-cell, and that the parent editor still has its own — the last being the only reading that distinguishes “withheld from cells” from “never installed”, which is how both earlier attempts passed while doing nothing. A precondition asserts a cell editor actually exists, without which the two cell assertions pass trivially. The unit side (test/unit/fold/bridge-metadata.test.ts, test/unit/util/surface-gate.test.ts) covers the detached-then-connected sequence the product actually performs, against real WASM. Seven negative controls are recorded in test/specs/table-surface-gate-negative-controls.md, including the one that caught a flaw in a test fake rather than in the product: an earlier closest answered while detached, which let the cell test survive a sabotage that should have broken it.

  • A 12-scenario characterisation suite for RPC behaviour around table cell editors (test/specs/rpc-table-cell-diagnosis.e2e.ts), crossing key type against table context and probing a fast API (nvim_get_mode), a non-fast one (nvim_win_get_cursor) and renderer liveness. It exists because the defect was first reported with a mechanism that source refutes: enqueueInput does not chain keys on prepareKeyInput(), which is await this.frontmatterFold.sync() and returns immediately once the mode matches. The suite’s decisive control presses the same prefix key with no table involved and reproduces the stall, which is what established that the stall and the unrecoverability are two different things. Acceptance is probed at a 35 s deadline, past the 30 s REQUEST_TIMEOUT_MS, so a request left pending cannot satisfy it; negative-control evidence is recorded in test/specs/rpc-table-cell-diagnosis-negative-controls.md.

  • Six scenarios for snippets in the owned table surface, plus five unit cases for the mirror’s new non-empty branch and seven negative controls in test/specs/table-snippets-negative-controls.md. Each control is also an isolation check — the count of surviving scenarios matters as much as the count of failing ones. Controls 1 and 2 fail identically (4 of 6), which is what proves the ordering of the Tab branch against the insert-mode return is load-bearing rather than incidental; control 3 removes only the navigation branch and fails exactly 2, so navigation and expansion are separately pinned; control 5 fails exactly 1 and is the only thing that can see a defect where the document, the parent selection and the session were all correct while nothing was visible in the cell; control 6 disables expansion globally and is what proves the outside-table control is not vacuous. Control 8 ran the same sabotage against the scenario pinning the repeated-tabstop gap, because a test asserting known-broken behaviour is worth nothing if it cannot fail — and that gap was then closed by the block-selection mirror, at which point the suite failed loudly and named the documents that had to change instead of silently contradicting them. That gap also had to be re-measured: the first reading was taken before the mirror fix and showed an insertion beside the field rather than a replacement of it, which would have sent the fix to sync-up.ts’s minimalDiff — a different defect in a different place. minimalDiff was left alone.

  • The table formatter is now pinned against Obsidian’s own, nine specimens compared byte-for-byte with a live TableEditor.rebuildTable() in native mode, plus eleven unit cases for fast feedback. realignTableLines had no unit coverage at all beforehand. The planned change this guards — a displayWidth() helper replacing String.length — was not made, because measuring the oracle showed Obsidian pads by UTF-16 length too (日本語 → column width 3, not 6; a ZWJ emoji → 8, not 2), so the helper would have broken the byte-for-byte match the same step required. Visual alignment of the owned grid belongs to the decoration layer, where source padding is explicitly not the layout engine.

  • The first version of that spec was vacuous and the negative control is what caught it. It drove :tablerealign against the document, and in native mode Obsidian’s widget reformats the table immediately afterwards — so the document converged on the oracle whatever our formatter produced, and a deliberately wrong width model reported 9 of 9 passing with the sabotage verified live in the source. The spec now calls the pure function directly through a new formatTableLines() accessor, after which the same sabotage fails exactly the four specimens where the two width models disagree and keeps the five where they agree. Two habits made it findable: verifying the sabotage was present before believing its result, and predicting the per-case isolation in advance so an all-green run was recognisable as wrong.

  • Four scenarios for the block mirror plus five unit cases for mirrorRanges, with two negative controls in test/specs/table-block-mirror-negative-controls.md. Control 1 forces the single-range path and fails exactly the two block scenarios while all 13 table-key-router scenarios — including Vjd — keep passing, which is the pairing that proves the block and linewise assertions test different things. Control 2 removes allowMultipleSelections and fails the same two, which is the half that is easy to miss: the mirror dispatches three ranges either way, so the mapping can be wholly correct and the user still sees one row. Two of the four scenarios deliberately survive control 1 — the linewise one guards the unchanged path, and the block-delete one measures the parent’s operation, which is right even while the display is wrong, so “the document came out right” was never sufficient evidence for the mirror.

  • Two harness errors are recorded there rather than silently fixed, because both read as product defects. browser.keys(['\u0016']) sends a raw control character that never arrives with ctrlKey: true, so <C-v> did not reach the router and the first run reported one range — appearing to contradict the measurement the step is built on. And a hand-counted expectation for the block delete used six spaces where removing two characters leaves four; it now derives the expected document instead of counting.

  • Three scenarios for owned-surface typography, with two negative controls in test/specs/table-typography-negative-controls.md. Monospace is checked by advance width — twenty is against twenty Ms — not by font name, because a theme naming a proportional family in --font-monospace would satisfy a name assertion and still break the grid. Control 1 fails both grid scenarios and deliberately leaves the parity one passing, since both renderers go proportional together and still agree with each other: parity and monospace are separate properties.

  • Control 2 did not fire, so the rule it tested was deleted. A padding: 0 reset had been added on the theory that CodeMirror’s default .cm-content/.cm-line padding caused a measured 12px passive/active delta. It did not move the delta, and probing the element chain showed why — both rows already shared an identical left, and the 12px was entirely in width, because a passive row is a block div filling its container while a .cm-line is sized by CodeMirror’s content box. The assertion was comparing incomparable boxes; it now measures the rendered text via a Range, after which the two agree within 1px and the CSS rule is provably unnecessary. Shipping CSS no test can detect is the skipInTableCells() shape this project has already paid for once.

  • Twenty-one unit cases for the layout model and six scenarios for the decorations, with six negative controls in test/specs/table-cell-decorations-negative-controls.md. The alignment control fails exactly one scenario while every geometry assertion passes, which is what proves “the columns line up” and “the columns carry the right alignment” are different properties. The line-offset control is the one worth keeping: an off-by-one in the row accumulator is invisible on a single-line fixture and misplaces every decoration from the second row onward.

  • One control shows a plan premise to be wrong, and it is recorded rather than quietly satisfied. Plan E1.4 predicts that reusing .cm-table-widget on the nested container makes the treesitter bridge withhold its parser and the cursorline rule hide the surface’s cursorline, and that the surface-gate suite will fail. Measured with the class actually applied, table-surface-gate stayed at 2 passing and table-cell-cursorline at 3: the nested editor receives no registerEditorExtension extensions, so the bridge never reaches it, and Plan G had already measured cursorline absent from cell editors and deleted the rules. The constraint is kept and pinned, but as a forward-looking convention rather than a consequence anything can currently detect — a reader trusting the plan would wrongly believe two other suites were guarding it.

  • A working implementation was removed before shipping. Visible alignment inside the nested editor — hide n trailing pad characters, draw n real spaces before the content, total row width unchanged — passed and was deleted: Markdown pads on the right, so the mechanism is the “leading-space rewriting” the step’s own triage rules out, the effect is cosmetic on a surface whose job is to show source faithfully, and it cost a WidgetType plus two CSS rules on every table render. The step’s prescribed alternative is not implementable as written either, since text-align is a no-op on an inline span and a spec asserting it would pass while nothing moved; the alignment class is asserted instead.

  • Five scenarios for the passive grid, with three negative controls in test/specs/table-passive-grid-negative-controls.md. The informative one is control 1: substituting │ for | fails character identity and passes the 1px geometry-parity suite, because a box-drawing glyph has the same advance in a monospace face. Geometry parity is necessary and not sufficient, which is the measurement that justifies keeping both assertions instead of folding one into the other. Control 2 is its mirror — rows left as plain text keep character identity while producing no cells at all, which is exactly what the renderer did before this step.

  • The spec caught a real divergence while being written. The passive renderer emitted 12 cell spans where the nested editor emits 9: buildTableLayout does produce cells for the separator row — only the column-width accumulation skips it — and the active decorations drop them with an explicit check the passive painter lacked. The two surfaces are meant to be interchangeable for styling, so a divergent class count is a defect rather than a cosmetic difference.

  • Five scenarios for the realign guard, with three negative controls in test/specs/table-realign-guard-negative-controls.md. Control 1 asserts the guard directly rather than through dot-repeat, as the step requires, and the isolation shows why: the dot-repeat scenario keeps passing under that sabotage because it requests its realign from normal mode — dot-repeat is the symptom the guard prevents and cannot stand in for the guard. Control 2 covers the focus defect, which nothing in the plan anticipated: the plan predicts an undo granularity problem and prescribes isolateHistory, where the real fault was undo being entirely dead from a focus handoff in a different file.

  • Control 3 did not fire, so a third unverifiable rule was deleted — after skipInTableCells() and the owned surface’s padding reset. The planned composition guard is unreachable, because every realign entry point is a normal-mode operation while composition happens in insert mode, which the insert-mode guard already refuses. It is also unverifiable: a scenario written for it failed on its own precondition, since view.composing stays false after a synthetic compositionstart — CodeMirror sets composing to 0 there and the getter tests > 0. That precondition assertion is what turned a would-be passing guard into a visible finding.

  • isolateHistory was not used and is not needed. @codemirror/commands is not a declared dependency, only a transitive one, and ships its own nested @codemirror/state that TypeScript rejects outright; the annotation is an AnnotationType instance, so a copy of it is not the one the host’s history reads and could compile while silently doing nothing. Measured, the behaviour it was meant to buy already holds once focus is correct: one u undoes the realign and leaves the typed character, a second removes it.

  • Four scenarios for clicks, the context menu and the block clipboard, with three negative controls in test/specs/table-click-and-clipboard-negative-controls.md. The context-menu and clipboard scenarios survive the up-sync control, which is what shows the four are not testing one thing four times.

  • One control had to be run against a different suite, and that is the finding. The up-sync’s loop guard is invisible to the spec that motivated it — the early return on head equality already breaks the obvious feedback loop, so removing the guard leaves all four scenarios passing. Against the suites that exercise a mirrored multi-range selection it fails loudly: visual mode reads normal instead of visual, the status bar loses v-line, and a snippet tabstop’s selection reads empty instead of page. The mechanism is that mirroring the parent’s selection down is itself a selection update, and an unguarded up-sync reads only main.head and pushes a collapsed selection back, destroying every mirrored range by way of its own mirror. A bidirectional sync’s loop guard cannot be validated by the feature that motivated the sync.

  • Seven scenarios for the owned surface under RPC, with four negative controls in test/specs/rpc-table-owned-negative-controls.md, skipped when nvim is absent. The key-router control uses an installation spy rather than key behaviour, because the parent’s capture handler stops the event before a child listener would run — so routed stays 0 whether the router exists or not, and a behavioural control would be unfalsifiable.

  • The gate control did not fire at first, and the reason was a hole in the spec’s setup. beforeEach selected owned before the connection came up, and the gate is evaluated when the extension slot is built — so isExternalBackendActive() was still false and a fully restored blocker passed every assertion. That also means the original blocker was inert in that ordering: owned would have rendered under RPC before this plan touched anything, provided the mode was chosen first. Reordered to RPC-then-mode, which is the realistic flow, the control blocks the surface outright.

  • Neither half of the child’s inertness is individually detectable, which is recorded rather than hidden. focus() on a non-editable contentDOM is a no-op, so contenteditable=false alone and the focus guard alone each leave all seven scenarios passing; only removing both fails. Both are kept, as the plan mandates, with the control table as the honest record. The joint sabotage’s second failure is the more useful one: with the child focused the parent’s cursor does reach Neovim’s row, which is a direct confirmation that the focused child is what parks the parent’s selection — and therefore why the cursor limitation is the price of not competing with RPC.

  • A divergence that looked like a regression was shown to be pre-existing by a control. Under RPC the CM6 document and Neovim’s buffer disagree on a table’s column widths, because Obsidian’s own table editor reformats CM6 while Neovim holds the source. The same divergence is measured in native mode with the identical sequence, and the two differ only in padding — asserted by comparing both with whitespace and dashes stripped. Without that control the finding would have been attributed to this change and chased in the wrong place.

  • Fourteen scenarios for structural commands in owned mode and twenty-two unit cases for the operations, with four negative controls in test/specs/table-structural-owned-negative-controls.md. Controls 1 and 2 are deliberately crossed — removing the fallback fails the eight scenarios that expect a change and none of the absence scenarios, while removing the in-table guard fails exactly the outside-table one — so neither can stand in for the other.

  • Control 3 exposed a split in what each suite can see. Making column operations skip the separator row leaves all fourteen e2e scenarios passing, because realignTableLines rebuilds the separator from the data rows’ column count — but it does not rebuild the alignment markers, which it reads back out of it. The e2e fixture is |------|------|, every column unaligned, so there is nothing to misplace; the unit fixture is |:---|---:|:--:| and fails exactly the two alignment cases. The e2e proves cell counts, the unit tests prove alignment, and neither substitutes for the other.

  • The reuse inventory E2.1 requires is recorded as a measured table, one row per key with its native and owned result. It is what reduced the plan: one of eleven commands was reusable as-is, and the remaining ten needed a text implementation rather than a reimplementation of Obsidian’s widget.

  • Three specs added in this release failed in CI while passing locally, and each failure was a test defect rather than a product one — except the first, which was both. The cursorline spec asserted an element count, a property measured as 0 locally and 1 in CI; it now asserts the painted background, which is what a user sees, and carries a scenario that injects a probe element to test the suppression rule directly so it is verifiable wherever the decoration happens not to apply. The scroll-parity spec asserted owned === native, pinning owned to native’s own defect, and failed the moment owned improved; it now asserts “at least as good as native”, the same frame its sibling scenario already used. The right-click scenario measured the host rather than the product, twice over: WebDriver’s right-button pointer action produces no contextmenu event on macOS, and a dispatched event produces a DOM .menu on Linux and Windows but none on macOS. It now asserts Obsidian’s editor-menu workspace event instead, which is the portable evidence that a right-click inside the owned surface reaches the editor’s context-menu pipeline, with a validity check that the listener is not already firing. defaultPrevented is deliberately not asserted either way — Obsidian cancels the event in order to show its own menu, so true is correct where it handles it.

  • A cursor guard parameterised over every table surface, committed skipped because it fails on three open defects rather than on mistakes of its own — evidence in test/specs/table-cursor-surface-parity-findings.md. It exists because the existing suite structurally cannot catch this regression class: counted across the four prior fixes’ specs, there are four cursorOnTableLine === false assertions and one parentCursorVisible === false, and not one asserting that a cursor is drawn. A suite built only from absences is satisfied by a cursor that renders nowhere, which is the state owned is in — and every one of those specs pins tableWidgetMode = 'native', so none could see the other surface. The new spec iterates ['native', 'owned'] and asserts presence of a non-zero-extent cursor paired with its absence inside the table.

  • The DOM probing was abandoned for instrumentation, and that is what resolved it. Five DOM samples gave three different answers; logging suppressed, cursors.length, hasFocus and the layer’s child count from inside the fork’s BlockCursorPlugin gave one. It identified the flakiness as Obsidian’s transient widget flipping our own predicate, and it identified the remaining defect precisely: in owned the parent is correctly suppressed and nothing else draws a caret, because the nested editor is built without the vim extension. A fork-side hypothesis — a single-shot recovery hole in drawSel, where a zero-cursor measure cannot restore a cleared layer — was implemented, measured to change nothing (the retry is gated on !drawSuppressed, which is false here), and reverted rather than published unverified.

Documentation

  • docs/features/tables.md gains an “Owned table surface” section covering what works, what is not supported yet, and the five conditions under which the mode falls back to native. Its keybinding table is single-sourced in docs/reference/keybindings.md and transcluded, per the project’s rule against duplicating keybinding tables. docs/configuration/settings.md lists owned as a tablewidget value and states that native remains the default.

  • Corrected a documentation fix from earlier in this release, which was itself wrong. Two claimed table auto-formatting features do not exist and were removed: “Separator generation: typing || … generates a correctly formatted separator row” has no implementation at all, and Vim Motions never realigns a table on its own — its realignment is reachable only from :tablerealign, <Leader>tr and =. But the accompanying claim that no realignment happens was false, and is now corrected: Obsidian’s own table editor does realign a table’s columns when it commits a cell edit. The first probe only moved the cursor and never edited a cell, so it could not observe the trigger — the same class of error as measuring the wrong element for horizontal scroll. Re-measured with a committed edit in a misaligned table, the header is padded to match in both native configurations and is left untouched in owned, because owned replaces the widget that does it. That loss is now documented as a limitation of owned rather than described as behaviour nobody has.

  • Corrected the un-reproduced widget-feature list, and removed column resizing from it. Obsidian’s table widget has no column resizing: measured, there is no colgroup, no width style on any cell, and no method matching /resi|width/ among the widget’s 63 prototype methods — the grip handles move columns and rows. So it is not a capability owned mode loses. The same enumeration found three features the previous count of “nine” had omitted: column and row drag-to-reorder, sort by column, and the column context menu.

  • Corrected a false claim this release had already published. KNOWN_LIMITATIONS.md and docs/features/tables.md stated that neither table mode scrolls horizontally, “measured”. The measurement read .cm-table-widget’s .table-wrapper descendant and the editor’s scrollDOM — two elements that never scroll — rather than .cm-table-widget itself, which is where overflow-x: auto lives. Native reaches a scroll offset of 623 over the same key sequence. Re-measured by surveying every candidate container’s computed overflow and scroll metrics; the survey also exposed the owned-mode regression fixed above. Only scrolloff=100 genuinely breaks horizontal scrolling, and it does so in both modes.

  • KNOWN_LIMITATIONS.md enumerates the deferred losses individually rather than implying them: idle cells rendering as plain text, the unreconciled table-nav overlay, visual block as an enclosing span, horizontal scrolling no better than native, untested snippets, and the nine Obsidian widget features that are not reproduced — row buttons, column buttons, the context menu, column resizing, multi-cell selection, copy/paste of a cell selection, malformed-table handling, click-to-place-cursor and alignment-aware rendering. Not enumerating them is how a mode like this collects one bug report per item.

  • The owned setting description no longer says editing is unsupported, in both settings implementations, because it is: normal, visual and insert mode all work. The prior wording shipped when the renderer was display-only.

  • KNOWN_LIMITATIONS.md’s RPC/table-overlay entry claimed the table-nav overlay “needs nothing” under RPC. That measurement covered the overlay only and never covered cell editing; the entry now records the distinction and the Escape fix.

  • KNOWN_LIMITATIONS.md described set tablewidget=raw as showing “raw markdown table syntax”. It does not: the widget is hidden with CSS while Obsidian still block-replaces the source, so no .cm-line in the table’s range renders at all and the table is invisible while remaining navigable by j/k. The entry now records that, points at Source mode as the supported way to edit table source, and notes that suppressing the decoration — rather than hiding the element — is measured as achievable via a highest-precedence block replace from a state field.

  • docs/features/snippets.md gains a “Snippets in a table cell” section, and its existing [!bug] callout now distinguishes owned from native instead of saying tabstops do not work in tables generally. The native limitation and its KNOWN_LIMITATIONS.md entry survive rather than being replaced, because Obsidian’s per-cell editor still owns the keys there. Both the feature page and docs/features/tables.md record the two conversions Obsidian’s own table editor performs and this mode does not — a literal | in a snippet body opens an extra column, and a newline does not survive — along with the inactive tabstops carrying no highlight in a cell.

  • docs/features/tables.md no longer lists visual block as unsupported in the owned surface, and its widget-feature list now distinguishes keyboard multi-cell selection (supported) from the mouse equivalent (still a regression). The selection-mirror.ts contract note that said “a rectangular selection cannot be drawn with one CM6 range” was stale once the mirror stopped using one range, and is corrected.

  • docs/features/neovim-backend.md gains a “Tables” section stating what the presentational surface keeps and what it gives up, and docs/features/tables.md and KNOWN_LIMITATIONS.md carry the same two costs. Plan C’s C3 acceptance — that a cursor push makes nvim_win_get_cursor agree with the parent’s cursor — is recorded as unachievable rather than quietly dropped: the parent cannot park a caret in a block-replaced range without a focused child, and a push does not help because CodeMirror relocates the selection straight back.

  • Corrected the owned-surface documentation, which this release sequence had made stale in seven places. KNOWN_LIMITATIONS.md still said the surface falls back to native under “five conditions” including a connected Neovim backend (now four — RPC renders), that visual block is an enclosing charwise span (fixed), that snippets are untested and <Tab> is consumed by the router (fixed), that the cell context menu, click-to-place-cursor, keyboard multi-cell selection and malformed-table handling are unreproduced (all four now work), that scrolloff=100 kills horizontal scrolling in owned and native alike (owned no longer does), and that idle cells render as plain text (they render as a marked-up grid). docs/features/tables.md carried the same claims plus a fallback-table row for the Neovim backend. Both now agree with each other and with the code.

  • CONTRIBUTING.md was missing the entire src/vim/table/ subtree — twelve modules, not only the three added here — and described table-operations.ts as “row/column manipulation”, which moved to table/structural.ts when that file became the realign guard. The tree is now complete with a one-line purpose for each module, including why cell-decorations.ts must not use .cm-table-widget and why the passive grid is not an HTML <table>. AGENTS.md’s tree is deliberately untouched: it is scoped to Lua/RPC compatibility modules and has never covered the table surface.

[1.4.0] - 2026-09-30

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.

[1.3.1] - 2026-09-29

Fixed

  • A FileType handler in your own Neovim config silently disabled the rendered-frontmatter fold from the second note onward — activateDocument() runs filetype detect on every activation, which re-fires the user’s FileType handlers, and setting a window-local fold expression there is ordinary Neovim configuration. NeovimFrontmatterFold.sync() installed its expression once at connect and then early-returned for the rest of the session, so the handler landed after it on every subsequent activation and nothing put it back: the first note was folded correctly, and every note after it was not. The frontmatter guard is a correctness property rather than a preference — the properties widget stays owned by Obsidian and Neovim’s cursor must not enter the region — so it silently stopped holding after the first pane switch. Measured on Neovim 0.12.5 against both routes into that handler: g:markdown_folding, where the stock Markdown ftplugin sets foldexpr=MarkdownFold(), and a treesitter foldexpr in a personal ftplugin, which is the commoner one. MarkdownFold() is additionally wrong for frontmatter rather than merely different — tag: alpha followed by the closing --- matches its setext-H2 rule, measured foldlevel(2)=2, so the properties line starts a fold instead of sitting inside a protected one. The fold expression is now reapplied per activation. foldlevel and foldenable deliberately are not: no FileType handler writes them, and rewriting them there would undo a user’s own zm/zM on every pane switch. sync() keeps its early return because prepareKeyInput() awaits it before every delegated keystroke; the reapply lives on a separate activation-path method so the per-keystroke cost is unchanged. (#199)
    • Plugin: src/rpc/frontmatter-fold.ts (syncForActivation(), splitting the connect-time install from the per-activation expression reapply), src/rpc/document-sync.ts (activation call site)
  • Every fold in the focused pane arrived closed under the Neovim backend, and appeared to spring open again when focus moved to another pane — the mirror window was activated with foldlevel set to 0 whenever Settings → Editor → Properties in document was anything other than Source, which is Obsidian’s default, so a note opened as if zM had been pressed. 0 was chosen to close the frontmatter fold, and it does, but it closes every heading fold with it. The expression fold marks frontmatter with a sentinel written as 100, and Vim caps an expression fold at MAX_LEVEL, 20 — foldnestmax does not move that cap, measured at 5 and 10, both still reporting 20 — so the level that closes the frontmatter and nothing else is 19, not 0. Raising it to 99 instead is the other wrong answer: the sentinel never reaches 99 either, so the frontmatter fold opens and Neovim’s cursor gets the properties widget back. The sentinel is now written as the cap it resolves to and the window’s level as one below it. The apparent zR on the pane being left needs no separate cause and is not a defect: CM6 folds are per-pane and the decoration bridge unfolds the pane it stops mirroring, so an all-closed pane visibly reopens as focus leaves it. Reproduced on Linux against Neovim 0.12.5; never platform-specific. (#199)
    • Plugin: src/rpc/frontmatter-fold.ts

Tests

  • A new spec measures fold state as the activation leaves it, which no existing spec did. rpc-folds-undo.e2e.ts sets foldmethod, foldexpr, foldlevel=99 and foldenable and runs zR in its beforeEach, so the window it measures is never the window the product produced — the same fixture-hides-product shape as the vim.opt.swapfile = false line removed for the swap-file defect two entries above. Nothing in the new spec sets a fold option or runs zR. It also runs with propertiesInDocument set to visible; every other RPC spec sets source, which is the branch that already worked, and the mode has to be in place before connecting because NeovimFrontmatterFold caches it and reapplies nothing once it matches. Red first: all four scenarios failed, foldclosed() per line returning [1, 1, 1, 1, 1, 1, 7, 7] over the eight-line heading fixture where -1 was expected — both top-level heading folds closed on arrival. Two further controls, each proven independently. Setting the window level to 99 failed the frontmatter scenario and nothing else, at closed: -1, closedEnd: -1 against 1 and 3, which is what makes that scenario the guard against the tempting fix rather than a duplicate of the others. Setting foldmethod to manual, so no folds exist at all, failed three of the four and left the pane-focus scenario passing vacuously — deliberate, and recorded: foldclosed() cannot tell a pane with no folds from a pane with no closed folds, and the level-ladder assertion [1, 1, 1, 1, 2, 2, 1, 1] in the first scenario is what refuses to let folding be absent. The pane-focus scenario seeds each leaf through its own editor rather than setupEditor, which re-focuses until it sees any focused CM6 editor and with two visible panes can already be the other one; seeding through it settled the mirror on the wrong pane and reported six lines where eight were expected. (#199)
    • Tests: test/specs/rpc-fold-focus.e2e.ts (new), test/specs/rpc-fold-focus-negative-controls.md (new)
  • Two further scenarios in the same spec are opposing controls on the per-activation reapply, because restoring the fold expression and leaving the fold level alone are separable and a fix that does one without the other is wrong in a different direction. Red first for the first of them: with a FileType handler installed and a pane switched, the frontmatter line returned level: 0, closed: -1, closedEnd: -1 against 20, 1 and 3 — the plugin’s sentinel replaced by the user’s expression. The handler is a constant 0 rather than a real treesitter expression so the effect is unambiguous and no parser has to be installed on the test machine; both real routes were measured separately against Neovim 0.12.5 and reach the window the same way. Each sabotage then failed exactly one scenario and left the other green: deleting the applyFoldExpression() call failed only the handler scenario, and adding foldlevel to the reapply failed only leaves a user fold level alone across a pane switch, at [-1, -1, -1, -1, -1, -1, -1, -1] against the [1, 1, 1, 1, 1, 1, 7, 7] a zM had produced. That second scenario is green both before and after the reapply was added — its job is to stay green, and the sabotage is what shows it is not decorative. (#199)

Documentation

  • AGENTS.md, CONTRIBUTING.md: the new spec and its negative-control file, and why it does not overlap rpc-folds-undo.e2e.ts.
  • KNOWN_LIMITATIONS.md: the RPC frontmatter paragraph now states that the window’s fold level sits one below the frontmatter fold so the body arrives unfolded, that Vim’s MAX_LEVEL cap is what decides that number, and that the fold expression is reapplied per activation because filetype detect re-fires the user’s FileType handlers.
  • docs/features/neovim-backend.md: a sentence in the properties-mode paragraph stating that only the frontmatter fold is closed on activation, which pairs with the existing note that fold persistence is unavailable in RPC mode, and a note that the plugin reclaims foldmethod/foldexpr on the mirror window while leaving foldlevel to you.

[1.3.0] - 2026-09-28

Added

  • Every built-in surround character can now be rebound — vim.obsidian.surround.set and surroundmap refused all 19 of them, which meant the commonest reason to reach for a custom pair was the one thing the feature could not do: ysiw( wraps as ( word ) and nothing could ask for (word). Registration now accepts them, and each dispatch that reads a built-in meaning before it reads a delimiter pair yields to an override — t and f as targets, where they reach the tag and function-call finders, and <, f and F as replacements, where they open the tag-name and function-name prompts. An alias resolves to its canonical character first, so overriding ) also reaches b; leaving it on the built-in would make dsb and ds) disagree about what the pair is. Overridden brackets keep depth-aware matching, because the search moves from the bracket scanner to the multi-character one, which counts depth as well. Empty delimiters are rejected instead of stored: they disable the character rather than rebinding it, a failure mode that only became reachable once a built-in could be the target. (#197)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (a shared getCustomSurroundPair resolver behind all seven lookup sites, the relaxed registerSurroundPair, and eight built-in dispatch guards — five on t/f as targets, three on </f/F as replacements)

Fixed

  • The Neovim backend’s version gate enforced 0.10, not the 0.12 it advertises — REQUIRED_API_LEVEL read 12, but 12 is Neovim 0.10’s NVIM_API_LEVEL; 0.11 is 13 and 0.12 is 14. Every 0.10 and 0.11 therefore cleared a gate whose own rejection notice, the docs and the README all say 0.12, and then died a few statements later inside the decoration bridge: companion.lua passes on_range to nvim_set_decoration_provider, a key that only exists from 0.12. Measured against the official release tarballs — 0.11.4 reports api_level 13 and answers that call with invalid key: on_range, 0.12.5 reports 14 and accepts it — and Neovim’s own CMakeLists.txt at v0.9.5/v0.10.0/v0.11.0/v0.12.0 gives 11/12/13/14. The floor is now 14 at all four sites that carry it. (#199)
    • Plugin: src/rpc/neovim-connection.ts
    • Scripts: scripts/install-neovim.sh, scripts/install-neovim.ps1
  • A Neovim-side startup error no longer claims the binary path is wrong — every failure after spawn() reused the notice written for failures before it, so an RPC error arrived as could not start Neovim at "…": <neovim error>. Check the configured path and permissions. The reporter of #199 spent days moving nvim.exe between directories on advice that could not have helped: the process was already running, which proves the path resolved and executed. Post-spawn failures now read Neovim started at "…" but the connection failed: … and point at the Neovim configuration instead.
    • Plugin: src/rpc/neovim-connection.ts
  • EACCES is no longer reported as a non-executable binary on Windows — libuv’s uv_translate_sys_error maps ERROR_ELEVATION_REQUIRED and ERROR_CANT_ACCESS_FILE to EACCES, while a plain ERROR_ACCESS_DENIED arrives as EPERM. “The binary is not executable” therefore describes the one Windows cause that does not produce it. Windows EACCES now names the Run this program as an administrator compatibility flag and non-native paths such as WSL or a mapped drive; EPERM is handled separately and points at permissions and antivirus; and every spawn failure now carries its errno code, so an unhandled one is diagnosable from the notice alone rather than being swallowed.
    • Plugin: src/rpc/neovim-connection.ts
  • The mirrored buffer left a swap file behind for every note visited, and the next session could not connect at all — the mirror carries the note’s absolute path and is permanently modified, so Neovim gave it a swap file like any other named buffer, one per note because the buffer is renamed rather than reopened. Disconnect sends SIGTERM, and Neovim’s signal handler preserves swap files rather than deleting them, so they accumulate by design. The next session names the same buffer and gets E325 Found a swap file — a blocking prompt an embedded Neovim cannot answer, so activation never finishes and the backend fails to connect, which is the reporter’s alternation between E325 and an unexplained startup failure. The mirror now sets swapfile false on itself, buffer-locally, so the user’s own buffers keep their configured behaviour. Existing stale swap files need no cleanup: with the option off, Neovim skips the check entirely — measured against a deliberately stale swap, which produced no prompt. Reproduced on Linux; it was never platform-specific, and the reporter’s Windows restarts merely surfaced it first. (#199)
    • Plugin: src/rpc/document-sync.ts
  • A snippet tabstop inside Markdown emphasis no longer jumps outside it in Live Preview — with the reported body $1 *a$2* abc$3, Tab from the first tabstop left the cursor at *a*| instead of *a|*, so the next keystroke typed outside the emphasis: *a*z abc where *az* abc was wanted. Read out of Obsidian 1.13.7’s bundle: Live Preview hides inactive formatting markers behind Decoration.replace, and the view plugin that owns them re-snaps a selection landing inside a hidden marker to its outer edge — min(from, pos) for the opening marker, max(to, pos) for the closing one. It tests the incoming selection against the decoration set built for the previous selection, so the snap fires on the very transaction that first moves the cursor into the markup, which is what a tabstop jump is; and it dispatches the correction from a zero-delay timer, so the cursor lands on the tabstop and hops out a tick later. Its own bail-outs decide the shape of the bug: it skips any transaction that also changed the document, which is why expansion still places the first tabstop correctly and only later jumps are affected, and it does nothing in Source mode, where no marker is hidden. A transaction filter now drops the correction — a bare selection move with no document change, no effects and no user event — when it starts from the selection a tabstop jump just produced and leaves it. (#198)
    • Plugin: src/snippets/live-preview-guard.ts (new), src/main.ts (installed in the snippetRuntime slot, so it follows enableSnippets)
    • The first version keyed the guard on EditorState identity and missed the snap entirely. The completion plugin dispatches its own selection-less, effect-only bookkeeping transaction between the jump and the snap, which consumed the guard: instrumentation recorded match: true, hasSel: false on that transaction and then armed: false on the snap at from: 3, to: 4. The guard is keyed on the jump’s selection instead, and is closed by the first transaction that moves the cursor or edits the document, plus a zero-delay release for the ordinary case where no snap comes. Ordering is safe by construction: the view plugin that schedules the snap runs in updatePlugins, ahead of the update listener that arms the guard, so Obsidian’s timer is always queued first.
  • cs dot-repeat changed the wrong pair when the cursor sat on the last character before the closing delimiter — on (test), "test", "test", "test" with the cursor on the final t of the second word, . produced (test), "test(, )test", "test", having matched that word’s closing quote against the next word’s opening quote. Typing cs"b by hand at the same position produced the correct (test), (test), "test", "test", so the repeat and the command disagreed. The repeat path shifted its search start forward by the length of the replacement’s opening delimiter on every iteration of the count loop, where the interactive path in handleSurroundSubState applies that shift only from the second iteration onward. The shift exists so a later iteration begins past the delimiter the loop just wrote and expands outward; on the first iteration it only moves the search off the cursor, and one column right of the last character inside a pair is that pair’s closing delimiter — which findSurroundingQuotes, scanning backwards for the nearest quote, then takes as an opening quote. Brackets hid it, because findSurroundingBrackets counts depth and recovers from the same displacement. The repeat path now steps past that delimiter only when the cursor is actually standing on it — which is where changeSurroundPair parks it, so csba.. still walks outward through (((test))) while a cursor merely resting inside a pair is left alone. Gating on the iteration number instead is not equivalent and breaks that walk: repeated . is a series of separate calls that are all the first iteration. (#197)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (surroundAction, the savedReplacement branch)
  • A surround pair dropped from .obsidian.init.lua stayed bound until Obsidian restarted — applyLuaSurroundPairs returned on an empty list before reaching its own unregister pass, so removing the last pair from the config released nothing. That was invisible while only new characters could be registered; with built-ins overridable it strands a rebound ( in a state the config no longer describes. The release pass now runs ahead of the guard.
    • Plugin: src/main.ts
  • surroundmap had the same gap, and tracked nothing at all — an explicit surroundunmap worked, but a surroundmap line simply deleted from .obsidian.vimrc survived the reload, because nothing recorded what the previous pass had registered. Triggers are now tracked and released on soft reload and on teardown, in the shape vimrcMapKeys and vimrcExmapNames already used two functions away.
    • Plugin: src/vimrc/loader.ts, src/main.ts

Tests

  • A new e2e scenario covers the notice split itself, because no unit test can see which of the two messages connect() selects. Its stub answers nvim_get_api_info with an accepted level and then exits, so the next in-flight request rejects and the failure lands after spawn. Negative control: dropping the spawned argument from the outer catch — the pre-fix behaviour — failed it both ways round, but the connection failed reporting Expected: true, Received: false and could not start Neovim reporting Expected: false, Received: true, with nothing else in the spec changing. The spec’s ACCEPTED_STUB_API_LEVEL restates REQUIRED_API_LEVEL, since a spec cannot value-import a src module, and the unit suite pins the two: drifting it to 13 failed on that field alone.
    • Tests: test/specs/rpc-lifecycle.e2e.ts
  • The RPC test fixture had vim.opt.swapfile = false in it, which hid the swap-file defect for the entire life of the backend — every spec ran clean while every real user accumulated swap files. Both fixtures now leave the global default alone, so the suite measures the product. Removing that one line, against the unfixed bundle, did not merely fail the new assertion: it broke the beforeEach hook with Neovim RPC did not connect, because the E325 prompt blocks activation — the defect is a total connection failure on reconnect, not a cosmetic one. The new scenario diffs the swap directory before and after rather than asserting it empty, because that directory is shared with the developer’s own Neovim; an absolute assertion failed spuriously on debris from an earlier control run. Negative controls, each proven independently: with the fix reverted the option assertion reported Expected: false, Received: true, and with that assertion inverted so execution reached the disk diff, a freshly created …%test-vault-IX0xSq%Target.md.swp appeared in the added set. Four control runs left four accumulated swap files on disk, one per run; the fixed run left none.
    • Tests: test/specs/rpc-text-sync.e2e.ts, test/fixtures/nvim/init.lua, test/fixtures/nvim/lsp-probe.lua
  • 7 unit cases in test/unit/rpc/neovim-api-floor.test.ts hold the floor against the keys companion.lua actually passes — read out of the Lua file rather than restated, so a seventh key added without measuring it fails — against the level the rejection notice advertises, and against the three sibling gates that carry the same number. 4 further cases cover the notice split. Red first: the floor cases failed with floor is api_level 12; these keys need more: [['on_start',13],['on_win',13],['on_range',14],['on_end',13]] and expected 14 to be 12. Negative controls for the rest: dropping only scripts/install-neovim.sh to 13 failed the consistency case on that field alone (installSh: 13 against installPs1: 14, prerequisites: 14); disabling the Windows EACCES branch reproduced the old string verbatim, the binary is not executable (EACCES).; and restoring the combined sentence produced the issue’s screenshot verbatim, could not start Neovim at "C:\Neovim\bin\nvim.exe": Neovim RPC error: […invalid key: on_range…]. Check the configured path and permissions.
  • refuses a Neovim API level below 12 stubbed level 11 only, so it stayed green for the whole life of the defect — 11 is below both the broken floor and the correct one. It is now a loop over 11, 12 and 13, the last two being exactly the levels that used to be admitted. Negative control: with the floor put back to 12 and the bundle rebuilt, level 11 still passed while 12 and 13 failed with "notices":[] — no version notice was ever shown, because the stub was admitted and the connection proceeded, which is the reported failure path. All 15 cases pass against a real Obsidian 1.13.7 and a real Neovim 0.12.5 with the floor at 14.
    • Tests: test/specs/rpc-lifecycle.e2e.ts, test/specs/rpc-prerequisites.ts
  • 5 e2e cases cover the Live Preview tabstop snap, over the reported body and a second one whose in-emphasis tabstop is last — a distinct dispatch path, since the autocomplete fork reaches the final field by clearing the snippet rather than advancing it. Red first, against the unfixed bundle: the cursor case reported ch: 4 against an expected 3, and typing after the jump produced " *a*z abc" against " *az* abc". Re-running with the guard removed after the fix reproduced both and the final-tabstop case at ch: 4; all three returned green once it was restored. Two of the five are controls that must stay green throughout and did — placement of the first tabstop, which lands outside the emphasis, and the whole sequence in Source mode, where nothing is hidden — so a failure is localised to the in-emphasis jump rather than to snippets in general. (#198)
    • Tests: test/specs/snippets/snippet-live-preview-tabstop.e2e.ts (new)
  • 3 e2e cases and 3 fork cases pin where the cs repeat starts its search, one at the reported position and two controls at positions that were never broken — mid-word, and on the opening quote — so a fix that displaces the search the other way fails rather than trading one wrong position for another. Red first: the reported case returned (test), "test(, )test", "test" against the expected (test), (test), "test", "test", byte-identical to the issue, while both controls passed. The fork’s browser tests import from .., which resolves to dist/, so a negative control there needs a rebuild between the stash and the run — stashing src/vim.js alone leaves the built bundle fixed and all three pass, which is a vacuous green. Rebuilt from pristine master, vim_dot_cs_quotes_cursor_before_closing_quote failed with the same string and the two controls held. (#197)
    • Tests: test/specs/surround.e2e.ts, ~/Repos/codemirror-vim/test/vim_test.js
  • The csba.. nested walk is now pinned at the fork level, where it had no coverage at all — the fork suite passed 1916/1916 against a rule that broke it, and only the plugin’s golden suite caught it, comparing against a real Neovim. dot_cs_nested_layers_step_outward asserts all three states of (((test))) → <((test))> → <<(test)>> → <<<test>>>. It and the #197 case are opposing controls on the same line of code: with the iteration-number rule the nested walk fails at the first . with <((test))> while the quote cases pass, and with the unconditional offset the nested walk passes while #197 fails with (test), "test(, )test", "test". Only the delimiter-under-cursor rule satisfies both.
  • 14 fork cases and 9 e2e cases cover overriding the built-ins, one per dispatch that had to learn to yield plus controls that must stay green. The controls earn their place: should leave other builtin pairs alone when one is overridden and surroundunmap should restore an overridden builtin pair both passed in every run, which is what localises a failure to the override path rather than to surround in general. Two independent negative controls, because relaxing registration and guarding dispatch are separable. Built against pristine master, the four e2e override cases failed with the built-in output — ( hello ) world for the paren, <<hello>> world for the tag target (the tag finder matching nothing), (hello) world for the alias — and the removal case failed on its own first assertion. Then, with registration relaxed but the guards stripped, exactly the five guard-dependent fork cases failed and the four that only need getSurroundPair stayed green: alias_follows_target returned (hello) world, tag_target and func_target deleted nothing, and both <-as-replacement cases opened the prompt instead of wrapping. A third control covers the alias ordering, which the docs now promise: swapping the resolver’s two lookups so the alias resolves first failed custom_surround_override_alias_takes_precedence with [[hello]] world for ysiwb, and left the other three alias cases green. custom_surround_reserved_char_rejected asserted the restriction being removed, so it is now custom_surround_multichar_trigger_rejected; the validation it covered is still tested, alongside the new empty-delimiter rejection. The fork suite is 1915 passing over 1916 cases, the one failure being the pre-existing vim_increment_octal. (#197)
    • Tests: test/specs/lua-config.e2e.ts, test/specs/vimrc.e2e.ts, test/specs/config-management.e2e.ts, ~/Repos/codemirror-vim/test/vim_test.js
  • The two config-reload releases have their own controls, since neither is reachable from the fork suite. Restoring the pairs.length === 0 early return failed should restore the builtin surround pair when its override is removed from init.lua with (hello) world, and removing the vimrc release pass failed removing surroundmap from the vimrc should restore the builtin pair the same way — while surroundunmap should restore an overridden builtin pair stayed green through both, proving the explicit-unmap path and the implicit-removal path are tested separately. loadVimrcSoft exists because loadVimrc restarts Obsidian, which resets the fork’s module state by itself and so cannot observe a leak at all.

Documentation

  • docs/features/neovim-backend.md: a Troubleshooting the connection section, which #199 asked for by name. Covers the version check and the commands to run it, a table mapping each failure notice to what it actually means — including that a notice beginning “Neovim started at” has already proved the path works — and a Windows section on pointing at nvim.exe rather than its folder, spaces in the path needing no quoting because no shell is involved, the elevation flag behind EACCES, and why WSL, .bat wrappers and mapped drives are not valid targets. The EACCES advice no longer prescribes a direction: the first draft said to clear the Run this program as an administrator flag, and the reporter then resolved it by switching that flag on, so the page now explains that it has to agree with how Obsidian itself is running and says which way worked in the reported case. An E325 row covers the swap files left behind by earlier versions.
  • CONTRIBUTING.md: src/snippets/live-preview-guard.ts in the codebase tree.
  • KNOWN_LIMITATIONS.md: a note under Visual mode on single-character text objects (Fixed) separating the formatting-mark transaction filter removed there from the snippet guard added for #198. The removed filter moved the cursor itself, on every transaction; the guard drops Obsidian’s own corrective dispatch, only immediately after a tabstop jump. Removing it is not a repeat of that fix.
  • KNOWN_LIMITATIONS.md: under Surround nvim-surround parity gaps, the bullet crediting cs dot-repeat to a “search position offset” now records that the offset was applied one iteration too early, and the stale test counts are replaced with measured ones.
  • docs/features/surround.md: the sentence listing 19 reserved characters is replaced by an Overriding the built-in pairs section — the spacing and curly-quote examples, a table of the three characters whose interactive behaviour an override displaces, the alias rule, and the empty-delimiter rejection.
  • docs/configuration/lua-config.md: the same reserved-characters sentence under vim.obsidian.surround, rewritten and pointed at the feature page.
  • docs/configuration/vimrc.md: a built-in override in the example vimrc, and surroundmap/surroundunmap in the command table now say they override and restore built-ins rather than only adding and removing custom pairs.

[1.2.0] - 2026-09-26

Added

  • Local typings for Obsidian’s core-plugin view trees — obsidian-typings declares Tree<T>, TreeItem and TreeCollapsibleItem but attaches tree to FileExplorerView only. Measured on 1.13.7, five views own an equivalent navigable tree: file-explorer, outline, tag, all-properties and bookmarks. changeFocusedItem() moves focusedItem in all five, while setCollapsed is present only in file-explorer, outline and tag. Resolution is by allowlist rather than by shape, because backlink (view.backlink.backlinkDom) and search (view.dom) carry a ResultDom — a different interface that also has changeFocusedItem, so duck-typing finds it. ResultDom is not inert: its changeFocusedItem takes 'forwards' just as Tree’s does, and on search it moves focus. backlink is unverified, its view having rendered no rows in two attempts. No user-visible behaviour changes; this is the groundwork for navigating those views, and follows the TableEditor route of declaring locally, proving in the plugin, then upstreaming.
    • Plugin: src/types/core-view-trees.d.ts (new), src/workspace/core-view-tree.ts (new)
  • Native File Explorer Vim navigation — when workspace navigation is enabled and the native File Explorer has focus, unmodified h/j/k/l reuse its arrow-key behavior, including counted movement capped at 100. They are ordinary GlobalMappingRegistry entries, so they remap and unmap like every other global binding: h/l under a new explorer gate that keeps them from being swallowed outside the tree, and j/k as the existing scroll entries with an explorer branch, since the registry stores one entry per key. Interaction tracking preserves navigation when Obsidian targets BODY and clears it on focus or pointer movement outside the explorer; pending chords such as <C-w>h are untouched. Rename controls, composition, modified keys, modals, editors, and other views retain their original keystrokes. (#191)
    • Plugin: src/workspace/global-key-handler.ts, src/workspace/global-defaults.ts, src/workspace/global-mapping-registry.ts, src/workspace/file-explorer-context.ts (new)

Fixed

  • A focused sidebar pane no longer scrolls the main editor — with the tag or outline pane focused, j/k/G/gg/H/L/<C-d>/<C-u>/<C-f>/<C-b> were intercepted and applied to the note behind them: five presses of j scrolled the editor 196 px. workspaceNavViewTypes does not list those view types, so the standard gate should have declined, but isPluginLeafActive() asked getMostRecentLeaf(), which is documented as returning the root-split leaf “while a sidebar leaf might be active” and so reported markdown in every case. The handler now tracks the leaf that actually gained focus through active-leaf-change and prefers it, falling back to getMostRecentLeaf(). The search pane was already protected by the focused-input guard, and the File Explorer by its own navigation. (#191)
    • Plugin: src/workspace/global-key-handler.ts
  • File Explorer j/k no longer depend on that bug — they are the standard scroll entries branching on explorer context, so once the gate correctly rejected sidebar leaves they stopped moving the tree. isPluginLeafActive() now declines to veto when the keystroke belongs to the File Explorer.
    • Plugin: src/workspace/global-key-handler.ts
  • Multi-digit counts now reach global key bindings — every count typed outside the editor was truncated to its first digit, so 12gt went to tab 1 and 30j scrolled three lines instead of thirty. The first digit is captured in the gateApplies === null branch of the gate block, and that branch returns unconditionally for any key it does not consume. The continuation accumulator sat below the gate block, so it was unreachable for a second digit — an unmapped digit always resolves to gateApplies === null and returns there. Single-digit counts were unaffected, which is why this went unnoticed; 3gt and its test have always passed. The accumulator now runs before the gate block, and carries a note that the ordering is load-bearing.
    • Plugin: src/workspace/global-key-handler.ts

Tests

  • keeps source-rendered frontmatter fully navigable is fixed, and it was test-side — the spec set propertiesInDocument to source, but something reverted it during the reconnect that follows, so the walk ran with visible and stalled at the first body line. The product was correct throughout: the fork’s gate reads getVaultConfig(app, 'propertiesInDocument') === 'source' live and reported false because that is what the config said. The tell was already in the spec — the visible test expects exactly the 7,6,6,6,6,6 the source test was failing with, so the source walk had silently become the visible walk. Forced on Linux, where it had never failed, by injecting setPropertiesMode('visible') before the walk: byte-identical to both CI observations. measureFrontmatterWalk now takes the expected mode and re-asserts it at walk time, and the forcing probe goes green with the injection still in place.
  • 3 further unit cases pin that File Explorer navigation is independent of workspaceNavViewTypes (both gates), and that the explorer is still driven when it lives in a main-area tab, where getMostRecentLeaf() does report file-explorer and would otherwise veto it. Negative controls: removing the explorer exception failed both main-area and sidebar cases with [] instead of ['ArrowDown']; making the explorer gate consult getNavViewTypes() failed the h case and both h/l translations with prevented 0 instead of 1.
  • 16 unit cases in test/unit/workspace/core-view-tree.test.ts cover the allowlist, tree resolution, count handling and collapsibility reporting; 12 e2e cases in test/specs/core-view-trees.e2e.ts re-measure every declared row against a real Obsidian and assert backlink/search stay excluded. Negative controls: adding backlink to the navigable list failed both the pinned-list case and the exclusion case, claiming all-properties collapsible failed the pinned-list case, and adding backlink to the e2e’s own list failed only that view while the real five stayed green. Two defects in the first draft of the e2e were caught this way and are worth recording — hoisting changeFocusedItem into a local detached it from its receiver and threw on this.focusedItem, and comparing focus by label reported “did not move” for bookmarks, whose rows render no text. A third, found later: the original survey reported search and backlink as inert because it read focusedItem.selfEl, which ResultDom items do not have — they expose el. search navigates fine when called correctly, so the exclusion stands on the two interfaces being different, not on one being dead.
  • 5 unit cases and 3 e2e cases in test/specs/global-nav.e2e.ts cover the sidebar gate: tag/outline/search/backlink must not scroll the editor, a main-area nav view still must, and the File Explorer’s j still moves the tree. Negative controls: removing the active-leaf preference scrolled the editor 77 px in the tag and outline e2e cases and failed 4 unit cases; removing the explorer exception left the File Explorer j dispatching no arrow. The unit mock also now collects every active-leaf-change subscriber, because capturing only the last one bound the FileExplorerContext listener instead of the handler’s and hid the fix entirely.
  • 4 unit cases in test/unit/global-key-handler.test.ts cover two-digit, four-digit, and trailing-zero counts dispatched to a builtin action, plus 12gt across a multi-key chord — a separate path, since the count has to survive a partial match and a timeout restart. Each was negative-controlled by restoring the pre-fix file verbatim: 12x delivered 1, 9999x delivered 9, 30x delivered 3, and 12gt delivered 1. All four returned green once the fix was restored.
  • 25 File Explorer unit cases and 7 Obsidian E2E scenarios cover arrow translation, counts and the 100-movement cap, focus/pointer/contenteditable gates, physical-key observation, <C-w>h, and the new registry behaviour: h/l are not swallowed outside the tree, j/k still scroll there, <C-w>j/k/l still reach their pane commands, h disappears when workspace navigation is off, and a user gmap h overrides the default. Every case was negative-controlled: registering h/l at the standard gate gave prevented 1, dispatched 1 instead of 0, 0; an unconditional explorer branch gave scrolls 0 instead of 1; removing the <C-w>j entry gave [] instead of ['editor:focus-bottom']; forcing wsNav true left h registered; blocking registry overwrite gave commands [] instead of ['app:go-back']; ordering explorer ahead of structural gave prevented 0 instead of 1; removing the focusin listener dispatched one unwanted ArrowDown; removing the synthetic-event guard reported <ArrowDown> as a physical key; removing the cap dispatched 9,999 instead of 100; and removing the contentEditable gate gave prevented 1, stopped 1 instead of 0, 0. (#191)
  • A truncated plugin-tarball download no longer kills a CI shard — scripts/fetch-test-plugins.sh piped curl into tar, so a response cut mid-stream was unrecoverable: codeload truncated a flash.nvim tarball, tar died with gzip: stdin: unexpected end of file, and the whole Windows shard failed before any test ran. A truncated body is not an HTTP error, so curl -f never caught it. Each tarball now downloads to disk with --retry 3 --retry-all-errors before extraction.
    • Plugin: scripts/fetch-test-plugins.sh

Documentation

  • AGENTS.md, CONTRIBUTING.md: the registry rule for global keys, src/workspace/file-explorer-context.ts, src/workspace/core-view-tree.ts, the locally declared core-plugin view trees, and the constraint that test/specs/** cannot value-import a src/**/*.ts module.
  • docs/configuration/remapping.md, docs/configuration/settings.md: the File Explorer keys are remappable like any other global binding, and Workspace navigation gates them.
  • test/flaky-inventory.md: three CI failures observed during this work. keeps source-rendered frontmatter fully navigable reproduced on Windows with diagnostics byte-identical to the inventoried macOS case, which ruled out the environment and led to the forced failure and fix recorded under Tests. It is now resolved as test-side, and is added to the main inventory table, which had been missing it. The other two — paints an embedded note the same way (macOS) and leaves the stock behaviour when the setting is disabled (Windows). Both are recorded as Unknown with the observed values; a passing re-run is not a classification.
  • AGENTS.md, CONTRIBUTING.md: File Explorer key handling.
  • README.md, docs/features/workspace-navigation.md, docs/reference/keybindings.md: native File Explorer navigation, counts, focus gates, and editable-control exclusions.

[1.1.1] - 2026-09-25

Changed

  • Both CodeMirror forks are now consumed from the npm registry instead of git URLs — npm 12 blocks a dependency’s install scripts unless the root package’s allowScripts policy covers them, and prepare counts as an install script for non-registry sources. A git dependency cannot be approved: an allowScripts entry for it, pinned (@saberzero1/codemirror-vim@6.3.0) or name-only, is still reported as uncovered, while a registry package with a postinstall (esbuild@0.28.1) approves normally. Both forks build their dist/ in prepare and commit no build output, so under npm 12 they installed as LICENSE, package.json and README.md alone — measured with the community scanner’s own analysis command, npm ci --allow-git=all --allow-remote=all --ignore-scripts, which left no dist/index.js and no dist/index.d.ts, breaking npm run build and typecheck together. Publishing to a scope we own and consuming through npm: aliases sidesteps the policy, because registry dependencies are exempt from it even when the published package still declares prepare — upstream @codemirror/autocomplete does, and installs clean under --strict-allow-scripts. The aliases preserve the original import specifiers, so no source file and neither overrides block changed, and the published output is byte-identical to what a git install built across all four files of both packages. The lockfile now carries no git or otherwise non-registry resolved entry at all, so the plugin also installs under npm 12’s bare defaults, where allow-git and allow-remote both default to "none", and no longer depends on any scanner escape hatch.
    • Plugin: package.json (@replit/codemirror-vim → npm:@saberzero1/codemirror-vim@^6.3.0, @codemirror/autocomplete → npm:@saberzero1/codemirror-autocomplete@^6.20.3), package-lock.json
    • Fork: ~/Repos/codemirror-vim — published as @saberzero1/codemirror-vim, with a new .github/workflows/publish.yml that publishes over OIDC trusted publishing with no token and is gated on the version being absent from the registry. The publish script is renamed to release, because publish is a real npm lifecycle hook that runs after upload and would have re-entered npm publish on every success. .gitignore now un-ignores .github: its blanket .* rule matched the new workflow, so git add skipped it silently, and the existing workflows survive only because they predate that rule. The workflow publishes with --ignore-scripts and sets an empty NODE_AUTH_TOKEN for its install step, because actions/setup-node’s registry-url writes an .npmrc containing a literal ${NODE_AUTH_TOKEN} that npm ignores but yarn 1 aborts on, and this fork’s prepare shells out to yarn.
    • Fork: ~/Repos/autocomplete — published as @saberzero1/codemirror-autocomplete, with the same publish workflow. A files allowlist now drops .direnv, flake.nix, flake.lock and .envrc from the tarball, which .npmignore never excluded: 14 files and 89.2 kB down to 9 files and 70.0 kB.

Fixed

  • The Windows CI Neovim installer no longer rejects a perfectly good Neovim — the API-floor check failed intermittently with a message that refutes itself, Neovim API level 14 … is below required level 12. The level was never the problem; the captured string was. Both installers read the probe’s output with 2>&1, and headless Neovim writes every message to stderr — msg_puts_printf in src/nvim/message.c ends at fprintf(stderr, …) for everything except the info_message branch that --version and --help take — so any warning was concatenated into the value that ^\d+$ then rejected. The warning was Neovim’s own: .github/workflows/e2e.yml sets NVIM_LOG_FILE: /tmp/nvim-debug.log for every job, Windows Neovim cannot create that path, so log_path_init falls back and exports __NVIM_LOG_FILE_WANT, and on VimEnter runtime/lua/vim/_core/log.lua:check_log_file reports log: "…" not accessible, logging to: "…". Linux and macOS can write /tmp/nvim-debug.log and never produced it. The intermittency is a 100 ms race and nothing else: check_log_file notifies through vim.defer_fn(…, 100), while the probe is -c 'lua io.write(vim.version().api_level)' -c 'qa' and normally exits well before that timer fires, so only a slow enough runner saw the warning at all. Both installers now capture stdout alone, where nothing but the io.write lands; the warning still reaches the job log, where it is worth reading. Forced against the official 0.12.5 release tarball with NVIM_LOG_FILE pointed at a directory and the quit deferred past the timer — stdout 14, stderr the warning — the old capture throws in real pwsh and the new one reports 14. A three-attempt retry was considered and rejected: the run that loses the race fails deterministically, so retrying turns one failure into three and would additionally hide a real API-floor violation. Dropping the merge also emptied the PowerShell throw in the case where Neovim writes nothing at all, so it now reports unknown, matching the shell script
    • Scripts: scripts/install-neovim.ps1, scripts/install-neovim.sh

Documentation

  • AGENTS.md: replaced the git-URL dependency mandate with the registry-alias rule, including why a git dependency cannot be allow-listed and the note that npm install will not re-resolve a spec changed from git to an alias; added a fork-publishing section covering the trusted-publisher filename coupling and the yarn/.npmrc interaction
  • CHANGELOG.md
  • docs/development/index.md: rewrote the dependency-URL warning as a dependency-spec warning and added a “Shipping a fork change” callout
  • test/flaky-inventory.md: recorded the root cause, the forced failure and the fix probe, plus the still-open Windows NVIM_LOG_FILE diagnostics gap the same investigation surfaced

[1.1.0] - 2026-09-25

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

[1.0.0] - 2026-09-22

Added

  • Neovim RPC connection lifecycle — adds an opt-in desktop-only backend foundation that spawns a user-configured Neovim, attaches with an in-repo msgpack-RPC client, requires API level 12 (Neovim 0.12+), reports actionable startup/crash errors, and terminates the child on setting disable, Vim disable, plugin unload, or failed attach. Milestone 1 intentionally does not forward keys, synchronize text, or bridge decorations.
    • Plugin: src/rpc/msgpack-rpc.ts, src/rpc/neovim-connection.ts, src/main.ts, src/settings.ts
  • Neovim RPC active-editor text synchronisation — mirrors the active Markdown editor into the single Neovim buffer on connection and leaf activation, then applies content-carrying nvim_buf_lines_event notifications to CM6 with byte-to-UTF-16 coordinate conversion. Neovim is authoritative for RPC-originated text; key delegation, multi-leaf buffers, decorations, IME, and frontmatter policy remain deferred.
    • Plugin: src/rpc/document-sync.ts, src/rpc/msgpack-rpc.ts, src/rpc/neovim-connection.ts, src/main.ts
  • Dedicated Neovim configuration setting — neovimConfigPath optionally points the backend at a minimal Obsidian-specific init.lua, avoiding terminal-only LSP, dashboard, and statusline startup. Empty preserves the production default of loading the user’s normal configuration. A configured path starts under --clean, prepends its directory to runtimepath, and loads only that file with -u; measured startup dropped from 115 ms to 24 ms locally.
    • Plugin: src/settings.ts, src/main.ts, src/rpc/neovim-connection.ts
  • Neovim RPC key delegation — while connected, a lifecycle-owned capture handler forwards every non-composition editor key with nvim_input, suppresses the bundled fork through setKeyInterceptActive, prevents CM6 input, and synchronizes cursor and exposed mode state after a blocking RPC barrier. Every active-leaf activation seeds Neovim from that editor. IME composition remains deferred to M6.
    • Plugin: src/rpc/key-delegation.ts, src/rpc/document-sync.ts, src/rpc/neovim-connection.ts, src/main.ts
  • Neovim RPC decorations — a bundled Lua companion registers one redraw-driven decoration provider, queries all namespaces over visible buffer ranges, and forwards highlight and virtual-text extmarks in buffer coordinates. The host attaches a fixed-size UI only as a redraw clock, discards grid events, maps byte columns through the text-sync coordinate path, resolves Neovim highlight groups, and clears CM6 decorations on disconnect. The companion remains a real .lua source file embedded into main.js by esbuild’s text loader; no runtime file or user configuration is modified.
    • Plugin: src/rpc/companion.lua, src/rpc/decorations.ts, src/rpc/document-sync.ts, src/rpc/msgpack-rpc.ts, src/rpc/neovim-connection.ts, src/types/lua-modules.d.ts, src/main.ts, esbuild.config.mjs
  • Neovim RPC write and read routing — the named mirror is now buffer-locally buftype=acwrite. A buffer-scoped BufWriteCmd routes :w and :w! through Obsidian’s editor:save-file command and clears Neovim’s dirty flag; a buffer-scoped BufReadCmd makes :e and :e! re-seed from the current Obsidian document with line-event echo suppressed instead of loading stale disk content.
    • Plugin: src/rpc/companion.lua, src/rpc/decorations.ts, src/rpc/document-sync.ts
  • Neovim RPC Obsidian feature bridge (M4a) — generates selected mappings and user commands from VimRegistration, dispatches every host action through one obsidian_action notification channel, and restores the files picker, Oil, Harpoon slot 1, vertical split, next-heading navigation, and lowercase :sidebar while Neovim owns editor keys. Lowercase commands register an uppercase Neovim command plus a start-of-command-line guarded abbreviation. Refresh and disconnect remove mappings, commands, abbreviations, and notification listeners before reinstalling.
    • Plugin: src/rpc/obsidian-feature-bridge.ts, src/rpc/neovim-connection.ts, src/rpc/key-delegation.ts, src/vim/registration.ts, src/picker/picker.ts, src/main.ts
  • Neovim RPC workspace and navigation bridge (M4b Batch 1) — extends the registry-driven bridge with pane focus/cycling, horizontal splitting, tab close/cycle/target actions, counted Ngt, previous-pane and alternate-file state, pane-to-tab moves, four go-to-definition variants, and 11 lowercase workspace ex callbacks. A general dispatch payload carries mapping counts and command arguments for later parameterized batches; guarded abbreviations keep command names inert inside substitutions.
    • Plugin: src/rpc/obsidian-feature-bridge.ts, src/vim/registration.ts, src/keybindings/action-registry.ts
  • Neovim RPC picker bridge (M4b Batch 2) — generates the remaining built-in picker leader mappings and picker ex callbacks in Neovim. The existing general command payload preserves grep queries and named picker sources, source-specific marks/register pickers and resume reuse the ordinary picker entry point, modal keys remain owned by Obsidian, and file selection re-seeds Neovim through active-leaf activation.
    • Plugin: src/rpc/obsidian-feature-bridge.ts
  • Neovim RPC Harpoon, marks, and jumplist bridge (M4b Batch 3) — generates every remaining Harpoon mapping and ex callback, :marks/:delmarks/:jumps, and host-owned <C-o>/<C-i>. Slot strings and mapping counts use the existing payloads; cross-note navigation waits for active-note re-seeding and restores the stored cursor in both CM6 and Neovim. Lowercase within-buffer marks remain Neovim-native, while uppercase cross-file mark motions are explicitly deferred.
    • Plugin: src/rpc/obsidian-feature-bridge.ts, src/rpc/neovim-connection.ts, src/rpc/document-sync.ts, src/vim/harpoon-nav.ts, src/workspace/global-defaults.ts, src/main.ts
  • Neovim RPC folding and undo-tree integration (M4b Batch 5) — keeps all fold and undo operations native to Neovim, forwards visible fold state beside extmarks on the existing redraw pass, mirrors matching CM6 folds, supplies a Markdown-aware window-local foldexpr, and bridges only the three Obsidian sidebar lifecycle commands. The sidebar renders Neovim’s native undotree() result; 64-bit msgpack integers are decoded for its timestamps. Fold persistence is intentionally unavailable under RPC rather than restoring host offsets into Neovim-owned state.
    • Plugin: src/rpc/companion.lua, src/rpc/decorations.ts, src/rpc/frontmatter-fold.ts, src/rpc/key-delegation.ts, src/rpc/msgpack-rpc.ts, src/rpc/obsidian-feature-bridge.ts, src/vim/undo-tree.ts, src/vim/undo-tree-view.ts, src/main.ts
  • Neovim RPC structural navigation and hard-wrap (M5a) — moves heading, level-specific heading, same-indent list, and Markdown-link motions out of the host feature bridge and into buffer-local companion mappings backed by Neovim’s bundled Markdown treesitter parsers. Counts and operator-pending ranges match the bundled fork. The mirror receives the configured textwidth, while native gq/gw and the stock Markdown ftplugin own wrapping.
    • Plugin: src/rpc/companion.lua, src/rpc/document-sync.ts, src/rpc/decorations.ts, src/rpc/neovim-connection.ts, src/rpc/obsidian-feature-bridge.ts, src/motions/register.ts, src/main.ts
  • Neovim RPC Markdown text objects (M5b) — installs buffer-local operator-pending and visual mappings for emphasis, inline code, math, strikethrough, Markdown links and wikilinks, fenced code blocks, nested blockquotes, callouts, HTML tags, table cells, and table rows. Native Markdown treesitter supplies structural ranges; native it/at supplies tag matching with fork-compatible count handling. Explicit visual ranges keep every operator bounded, and teardown removes every mapping.
    • Plugin: src/rpc/companion.lua
  • Neovim RPC floating-window bridge (M6a) — enumerates floats during the existing redraw provider’s on_end, forwards per-window config, buffer content, and extmarks without grid events or polling, and renders positioned Obsidian overlays with border presence and Neovim z-index. CM6 character/line metrics map terminal cells approximately onto proportional Markdown typography; cursor- and window-relative origins are resolved separately. Closed windows and disconnected sessions remove their overlays.
    • Plugin: src/rpc/companion.lua, src/rpc/decorations.ts, src/rpc/floating-windows.ts
    • Styles: styles.css
  • Neovim RPC IME composition bridge (M6b) — a cursor-positioned input target outside CM6’s contentDOM owns native composition and keeps preedit out of Neovim. Commits enter through nvim_input, preserving insert undo and dot-repeat; ordinary keys are suppressed during composition, while Escape, blur, mode changes, active-note changes, and disconnect cancel preedit and resynchronize ownership.
    • Plugin: src/rpc/ime-input.ts, src/rpc/key-delegation.ts
    • Styles: styles.css
  • Neovim RPC external-UI messages (M8a) — attaches the messages, command-line, and popup-menu UI extensions once, dispatches ordered redraw batches while rejecting unhandled grid events before further work, and routes D12 error, warning, and informational message kinds to severity-styled Obsidian Notices with a five-second duplicate cooldown. Routine undo, search, progress, completion, and command-list kinds remain silent; command-line and popup-menu rendering remain deferred.
    • Plugin: src/rpc/redraw.ts, src/rpc/messages.ts, src/rpc/decorations.ts, src/rpc/neovim-connection.ts
  • Neovim RPC external command line (M8b) — renders cmdline_show, byte-correct cmdline_pos, cmdline_special_char, and level-scoped cmdline_hide events in a themed editor overlay. First characters, prompts, confirm choices, and nested levels make typed commands, searches, vim.ui.input(), and generic vim.ui.select() visible without changing bundled-fork behavior; popup-menu completion remains deferred.
    • Plugin: src/rpc/cmdline.ts, src/rpc/neovim-connection.ts
    • Styles: styles.css
  • Neovim RPC popup-menu completion (M8c) — renders popupmenu_show, selection updates, and hide events as a themed four-column completion list. Command-line wildmenu uses byte-correct command-line anchoring, while insert completion uses Neovim grid cells and measured CM6 editor metrics.
    • Plugin: src/rpc/popupmenu.ts, src/rpc/cmdline.ts, src/rpc/neovim-connection.ts
    • Styles: styles.css
  • Neovim RPC status-bar mode ownership (M8d) — routes msg_showmode into the existing status bar, gives the externally supplied Neovim mode precedence over fork events while RPC is connected, and restores fork-driven mode text on disconnect.
    • Plugin: src/rpc/mode-status.ts, src/rpc/neovim-connection.ts, src/vim/mode-tracker.ts, src/main.ts
  • The table-nav overlay was never broken under the Neovim backend, and the documentation saying otherwise is corrected — no code changed. canActivate() gates on forkAvailable, which is !isBuiltinVimEnabled(app) — that Obsidian’s own vim is off, not that the bundled engine is driving keys — so the overlay already activated under RPC, and reading that as “requires the bundled engine” was the error. Measured under a live connection rather than argued: l moved the highlighted cell from column 0 to column 1 while Neovim’s cursor row stayed at 3 and the buffer was byte-identical, because Obsidian’s keymap scope consumes the key before the delegation listener on the editor sees it. The risk worth ruling out was the opposite one, a key driving both the overlay and Neovim, and it does not occur.
    • Docs: docs/features/neovim-backend.md, KNOWN_LIMITATIONS.md
  • Companion mappings and leader groups describe themselves to which-key plugins — the readable-description work covered the feature bridge but not the companion, which registers 18 structural motions, 26 Markdown text objects and the fold aliases as real Neovim mappings; all of them still carried ids like vim-motions-rpc-textobject:iC. They now read as prose, and checking the object table while writing the labels caught two I would have guessed wrong: iC is the code fence and io the callout, not the other way round. Leader prefixes are also registered as <Nop> mappings carrying only a group label, so which-key.nvim renders +find and +harpoon menus rather than a flat list of leaves. Nothing executes those stubs — Neovim resolves the longer binding — and the leader itself is never mapped, since mapping it would make it a complete binding and break every leader sequence.
    • Plugin: src/rpc/companion.lua, src/rpc/obsidian-feature-bridge.ts, src/rpc/neovim-connection.ts, src/main.ts
  • Hint mode works with the Neovim backend, and bridged bindings describe themselves — hint mode needed far less than expected: its overlay captures keys on the document in capture phase, ahead of the delegation listener on the editor, and stops propagation, so label keystrokes could never have reached Neovim. Only the trigger was missing, and the five hint* ex callbacks plus the hintMode leader mapping are ordinary registrations the bridge already installs. Flash and EasyMotion share that same captureKeys path, so their triggers are one allowlist entry away too; they are deliberately left out because s and <leader><leader> already mean something in Neovim. Separately, every generated mapping and command now carries a readable desc prefixed Vim Motions: rather than vim-motions-rpc:<id>, so :map, :command and which-key.nvim describe them in words. Which-key itself is not ported: it renders the plugin’s leader registry from the fork’s key stream, which is not the keymap in force under RPC, whereas which-key.nvim draws in a floating window the bridge already renders.
    • Plugin: src/rpc/obsidian-feature-bridge.ts
  • Cursor shapes and input-method switching follow Neovim’s mode — both read the bundled fork’s own state or its vim-mode-change event, which never moves while Neovim owns keys, so the per-mode cursor shape stayed on normal throughout an RPC session and automatic IM switching never fired. src/vim/external-mode.ts is one seam the connected backend publishes Neovim’s mode into, mapped to the plugin’s vocabulary; the animated cursor consults it ahead of the fork through resolveVimModeWithExternal, and the input-method watcher subscribes alongside the adapter event. It publishes at connect as well as on every key — a gap the new lifecycle scenario caught, since onMode only fires after a keystroke and the mode was null until the user typed — and clears on disconnect so the fork regains ownership. Which-key and hint mode stay on the fork’s event stream: they need key events rather than a mode.
    • Plugin: src/vim/external-mode.ts, src/vim/animated-cursor/controller.ts, src/im/im-mode-watcher.ts, src/rpc/neovim-connection.ts, src/main.ts
  • JSON snippets work with the Neovim backend through LuaSnip — the plugin’s snippet JSON is plain VS Code format with the standard variable set and nothing Obsidian-specific, so LuaSnip reads it verbatim; verified before building by expanding date from the bundled global.json through a real LuaSnip, which produced the resolved date rather than the literal variables. Set up Neovim now installs LuaSnip, writes the bundled snippets out of main.js to vim-motions-snippets/ beside the generated config, points the loader at those and at the user’s own snippet directory as an absolute path, and maps <Tab>/<S-Tab> to expand and jump — without an expansion key the block would load and do nothing. The Lua DSL remains incompatible: different namespace and registration signature, and its reactive f()/d() nodes call into Obsidian’s vault.
    • Plugin: src/rpc/config-export.ts, src/main.ts
  • The generated dial.nvim configuration restores the behaviours that made the feature worth having — its default augends are decimal, hex numbers, four date patterns and Japanese weekdays, so wiring only the keymaps would have lost booleans, hex colours and checkboxes, which are most of what the bundled increment adds over Vim’s own. Those three augends are now registered explicitly. Verified against the real plugin rather than asserted: flag = true → false, #aabbcc → #abbbcc, - [ ] task → - [x] task, 41 → 42. This also corrects a documentation claim that dates were unsupported by default; they are.
    • Plugin: src/rpc/config-export.ts
  • Set up Neovim installs and updates the plugins it configures, on request — the configuration export previously refused to install anything, on a reading of the Developer Policies that was stricter than the policy is: the “install or update their dependencies” clause targets a plugin pulling in what it needs unasked, not an install the user explicitly requested, which is why BRAT ships in the catalogue. Set up Neovim now previews the plugins Neovim will fetch or update and the configuration that will be written, and applies both only on confirmation, using Neovim’s own vim.pack into Neovim’s data directory. Installing is deliberately kept on the button rather than written into the generated file, so a file you require stays configuration. Measured before building: vim.pack.add and vim.pack.update are both non-interactive headless, the latter needing force = true to skip its review buffer.
    • Plugin: src/rpc/config-export.ts, src/rpc/neovim-connection.ts, src/settings.ts, src/main.ts, styles.css
  • --clean silently disabled Neovim package activation — with a configured Neovim configuration path the backend spawns under --clean, which strips the user packpath. vim.pack then clones a plugin to disk and never puts it on the runtimepath, so require still fails: an install that reports success and does nothing. Measured directly — rtp_has_plugin=false under --clean against true without it. The spawn now appends Neovim’s own stdpath('data')/site back, which restores packages without reinstating the wrapper-injected runtimepath --clean exists to exclude; a configured init.lua still loads.
    • Plugin: src/rpc/neovim-connection.ts
  • Generate a Neovim configuration from your settings — most bundled-engine features were modelled on a Neovim plugin, so Vim engine → Generate a Neovim configuration translates the enabled ones into nvim-surround, dial.nvim, spider.nvim, yanky.nvim, flash.nvim, and mini.operators, alongside the leader key and textwidth. It writes lua/vim_motions.lua beside the user’s Neovim configuration and does nothing until they add require('vim_motions') themselves; a Copy button serves the pick-and-choose case. The path must be under lua/ to be requireable — a file beside init.lua is not, measured — and it is resolved by asking Neovim for stdpath('config') rather than reconstructing it from XDG_CONFIG_HOME and NVIM_APPNAME, which M2a measured as unreliable against wrapper installs. Every block is wrapped in pcall(require, …) so the file loads with none of the plugins present, and it emits no install call: this configures what the user already has, never fetches it. Regenerating refuses to overwrite a file whose generated header is gone. A changed setting marks the file stale in the settings tab, and Regenerate automatically (default off) applies it without asking. Both settings implementations render this through one shared method rather than two copies.
    • Plugin: src/rpc/config-export.ts, src/util/external-fs.ts, src/settings.ts, src/main.ts, styles.css
  • Smart list continuation and yank highlight now work with the Neovim backend — both are driven by the existing plugin settings and need no Neovim plugin. o/O continues -, *, and + bullets, which the stock Markdown ftplugin does not do (formatoptions=jtcqln omits o and r). It cannot simply drop the ftplugin’s f comment flag: that flag serves two behaviours at once — it gives gq its hanging indent while stopping o repeating the marker — and removing it made gq re-bullet every wrapped line, which rpc-structural-nav caught. The continuation form is swapped in only for the duration of an o/O insert, through an expr mapping returning the key so count, undo, and dot-repeat stay native; numbered lists remain out of reach because comments cannot increment a counter. Yank highlight is reported by a TextYankPost notification and drawn by the host’s own renderer, so fade works as well as solid. The obvious extmark route does not: a yank changes no text, so the decoration provider never re-runs and the mark reaches CM6 only on a later redraw, long after the highlight expired. textwidth, previously a lone positional argument, now travels with both as one editor-options object.
    • Plugin: src/rpc/document-sync.ts, src/rpc/neovim-connection.ts, src/main.ts
  • Arbitrary Obsidian commands are reachable from Neovim — :ob and :obcommand join the feature bridge, so :ob theme:use-dark executes any Obsidian command by ID while RPC owns editor keys. The bridge is otherwise an allowlist of features this plugin registers, which by construction cannot enumerate commands owned by Obsidian itself or by other plugins; this is the one endpoint that escapes it. Both route through the existing { id, args } payload and the start-of-command-line guarded abbreviation, so :%s/ob/…/ stays a substitution.
    • Plugin: src/rpc/obsidian-feature-bridge.ts
  • Neovim backend crash breadcrumb — a persisted neovimToggleInFlight marker is written before a real RPC connect or disconnect and cleared once it settles, so a marker that survives a restart means the renderer died mid-switch. The next start clears it and explains what happened instead of leaving an unexplained lost window. The renderer crash itself cannot be caught in-process: the plugin’s JavaScript dies with the renderer, and Electron’s UtilityProcess is unavailable to plugins. Scoped to real transitions — a reload that neither connects nor disconnects writes nothing.
    • Plugin: src/main.ts, src/settings.ts
  • treesitter-handle-leak ast-grep rule — flags a TreeCursor allocated with .walk() whose enclosing scope never calls delete() on it. web-tree-sitter registers every handle with a FinalizationRegistry and delete() unregisters it first, so a dropped cursor is not a leak — it is a deferred free of its tree’s pointer (the registry holds this.tree[0], not the cursor’s own handle) from a GC callback, after the CM6 bridge has already freed that tree. Shown to fire on the real defect, getAllNodesOfType at 88ec375~1, and to stay silent on the fixed tree, per the repo rule that a new pattern rule must be demonstrated against a defect that actually shipped.
    • Coverage was measured against a probe of every form rather than assumed: it fires on const/let/var bindings never freed in scope, on a discarded node.walk();, and on a reassignment in a scope with no delete() at all, with no false positives across four correctly-freed shapes. The discarded-value arm uses a direct-child has deliberately — with stopBy: end it also matched cursor = node.walk(); and flagged a correctly freed reassignment. It is flow-insensitive, so a reassignment in a scope that frees some other cursor is a known false negative, accepted because the alternative flagged correct code. Tree and Query handles are out of scope, since parse() and new QueryWrapper() legitimately transfer ownership to a cache or caller; their owners are asserted by unit tests instead.

Changed

  • Minimum Obsidian version raised from 1.7.2 to 1.8.7 — RPC message severity styling applies its class to Notice.messageEl, which noticeEl predates; messageEl is available from 1.8.7. Vaults on 1.7.2 through 1.8.6 will not receive this release. README.md and the installation guide state the new floor.
  • The bundled vim fork’s own type declarations are used — src/types/codemirror-vim.d.ts declared Vim as Record<string, unknown>, shadowing the 166 members the fork’s shipped .d.ts exports and reducing every Vim.* access across the 19 files that import it to unknown. All 15 fork-specific exports it hand-declared (foldopenAnnotation, setCursorSuppressed, setKeyInterceptActive, …) were already shipped, so deleting it reports zero errors in src/ and test/ while restoring real checking. The casts that erasure forced are gone: escape-guard.ts now calls Vim.setIdleEscapeCallback directly, and the demand harness calls Vim.map/unmap/getOperatorfunc without a hand-written view. getBundledVimApi() keeps its conversion — the plugin’s own VimApi interface genuinely does not overlap the fork’s shape.
    • Plugin: src/types/codemirror-vim.d.ts (removed), src/vim/escape-guard.ts
  • RPC E2E provisioning is cross-platform and version-pinned — Linux uses the official Neovim 0.12.5 release tarball in the E2E container, while macOS and Windows install the matching official release in the workflow. Every installer prints nvim --version and rejects API levels below 12 before tests start.
    • CI: .github/docker/e2e-runner/Dockerfile, .github/workflows/docker-e2e-runner.yml, .github/workflows/e2e.yml, .dockerignore
    • Scripts: scripts/install-neovim.sh, scripts/install-neovim.ps1, scripts/neovim-version.txt
  • Neovim RPC frontmatter handling supports both properties modes — removes the Source-only connection refusal. Source frontmatter remains fully navigable; rendered frontmatter receives a dedicated-window foldmethod=expr fold using the same start-of-document delimiter rule as CodeMirror, and post-key cursor synchronization resolves foldclosed() positions to the first body line. Property-widget focus remains owned by Obsidian, while API edits can still update the complete folded document.
    • Plugin: src/fold/frontmatter.ts, src/fold/provider.ts, src/rpc/frontmatter-fold.ts, src/rpc/document-sync.ts, src/rpc/key-delegation.ts, src/rpc/neovim-connection.ts
  • The renderer no longer parses Markdown while the Neovim backend is connected — Neovim parses the same document natively, and the renderer consumers of its tree are dormant then: structural motions and Markdown text objects run as companion mappings inside Neovim, and fold state is mirrored back from redraw. The tree-sitter bridge now tracks the connection, so a connected session parses each change once instead of twice. It also removes the WASM heap growth that made retained tree-sitter nodes reachable — defence in depth, since that defect is fixed at the call sites. Verified both ways: 34 RPC tests pass with the bridge off, 33 non-RPC tests pass with it on.
    • Plugin: src/main.ts

Fixed

  • ]h and [h waited a second before moving — M5a added ]h1–]h6 and [h1–[h6 as descriptive aliases for the level-specific heading motions, which the plugin already binds to ]1–]6 and [1–[6. The aliases made ]h a prefix of six longer mappings, so every press hit the fork’s prefix-ambiguity deferral and waited out operatorshadowtimeout (1000 ms) before moving. The redundant aliases are gone; the numeric bindings are unchanged.
    • Plugin: src/motions/register.ts, src/rpc/companion.lua
  • A Neovim RPC request could stay pending forever — a request whose response never arrived left its promise unsettled with no bound, so a wedged Neovim silently hung whatever awaited it. Requests now reject after 30 seconds. The timer is taken from window, not the Node global, so it also fires correctly in Obsidian popout windows.
    • Plugin: src/rpc/msgpack-rpc.ts
  • A rapid Vim-mode toggle applied only its first half — disableVim and enableVim cleared their in-progress flag from a 500 ms timer armed in finally, so the returned promise resolved while the flag was still set. The opposite toggle arriving inside that window hit its own guard and was discarded with nothing to retry it, leaving Vim off and every extension-slot feature unregistered until Obsidian reloaded. Toggles are now serialized so both halves apply.
    • Plugin: src/main.ts
  • Heading motions became no-ops before the parser settled — ]h/[h and the level-specific motions consulted the treesitter tree and gave up when it existed but had produced no headings yet, which happens while parsing is still catching up. They now fall back to the regex scanner in that case, as they already did when no tree was available at all.
    • Plugin: src/motions/headings.ts
  • Two treesitter nodes were held across traversals that can free them — snippet context detection read a fenced block’s language from a node retained across a child walk, and the blockquote text object held a node across its .parent() ancestor walk. Both are the use-after-free shape behind the renderer crash class: the handle survives the tree that owns it and then returns stale data. Both now read through a cursor while traversing.
    • Plugin: src/snippets/context.ts, src/text-objects/blockquote.ts, src/treesitter/js-api.ts
  • RPC fold expression no longer rescans the complete document for every queried line — the window-local Markdown foldexpr previously fetched and rescanned all lines on each invocation, making an edit on a 2,004-line note effectively quadratic and pushing measured RPC operator latency to 145.9 ms p95. It now computes one linear fold-level table per Neovim changedtick and reuses it for the remaining fold queries.
    • Plugin: src/rpc/frontmatter-fold.ts
  • RPC requests wait for active-note re-seeding — programmatic Neovim requests issued immediately after returning from a host sidebar could race the asynchronous active-leaf activation and have their buffer update replaced by the later seed. Requests now await the document-sync activation promise before flushing keys or reaching Neovim.
    • Plugin: src/rpc/neovim-connection.ts
  • RPC notes now use a real Markdown buffer instead of the dashboard buffer — M2a previously wrote into Neovim’s intro buffer by forcing its modifiable option. With alpha-nvim loaded that buffer remained buftype=nofile and filetype=alpha, preventing Markdown plugins and filetype configuration from activating even though byte synchronisation passed. The backend creates one listed buffer, names it with the active note’s absolute path, makes it current, explicitly runs filetype detection, and reuses it across active-leaf changes. The buffer is now finalized as the acwrite mirror described above.
    • Plugin: src/rpc/document-sync.ts, src/rpc/msgpack-rpc.ts
  • RPC acceptance tests no longer load the developer’s Neovim configuration — both lifecycle and text-sync specs use the committed minimal fixture. A Lua marker assertion fails if the configured path is ignored or cleared.
    • Tests: test/fixtures/nvim/init.lua, test/specs/rpc-lifecycle.e2e.ts, test/specs/rpc-text-sync.e2e.ts
  • RPC leaf changes no longer overwrite the newly active note with the previous note — the M2b one-shot seeding path reused the previous Neovim mirror on every later activation and dispatched it into the new CM6 editor, allowing Obsidian autosave to persist cross-note data loss. Activation now always reads the newly active editor, renames the Neovim buffer, detects its filetype, and repopulates it while line-event echo is suppressed. No activation path writes an existing mirror into a different editor.
    • Plugin: src/rpc/document-sync.ts
  • Harpoon same-leaf navigation preserves stored cursor positions — changing the file in one leaf fired active-leaf-change after that leaf already represented the destination, so cursor tracking overwrote the destination pin with line 0, column 0. Same-leaf activations no longer treat the destination as the leaf being left, and navigation snapshots its target before asynchronous activation.
    • Plugin: src/main.ts, src/vim/harpoon-nav.ts
  • Oil sort cycling changes the rendered order — directory rendering always read the configured default sort value, so gs advanced an internal key that was never used. The manager now initializes its active sort from the configured default and renders with the cycled value.
    • Plugin: src/oil/manager.ts
  • Oil hidden-file toggle recognizes an unchanged buffer — dirty detection re-rendered the directory to compare strings, but rendering allocates fresh entry ids, making every unchanged buffer appear modified and blocking g.. It now computes changes against the existing Oil snapshot.
    • Plugin: src/oil/manager.ts
  • Oil path yanks update the unnamed register — y. now writes the selected path to the bundled Vim engine’s unnamed register as well as the system clipboard.
    • Plugin: src/oil/manager.ts, src/oil/keybindings.ts
  • g- and g+ navigate the undo tree again — both actions captured this.undoTree in a local at registration time, but activateUndoTreeForFile() swaps that field per note, so the capture was orphaned on the first file activation while edits kept recording into the live tree through buildUndoTreeExtension(). g- walked the empty orphan and returned at its if (!node) guard, doing nothing; where the orphan held history it applied those change sets and restored unrelated content. Both registration paths now resolve the field at call time.
    • Plugin: src/main.ts
  • Renderer crash on Neovim RPC disconnect — disconnectChild() sent nvim_command qa! and then awaited the child’s 'close' event, with a SIGKILL fallback, all inside the teardown path. That crashed Obsidian’s renderer with a SIGSEGV on a corrupted V8 compressed pointer, reproducible in the CI container at 24 of 46 runs of a spec that cycles connect-traffic-disconnect 14 times. The crash requires RPC traffic and a disconnect: 216 non-RPC tests, 432 traffic-free connect cycles, and 2,400 toggle-free requests were all clean, and stubbing the extmark, float, buffer-line, and cursor handlers changed nothing, so it was never the message handling. Teardown now detaches from the child, unrefs it, and defers SIGTERM with a SIGKILL escalation to a later turn; nothing is awaited and both timers re-check exitCode/signalCode. This is a mitigation, not a cure — arms that drop the synchronous quit-and-wait total 7 of 38 runs against 24 of 46 for the rest (Fisher p = 0.002, roughly a 3x reduction), and a residual path survives. See known limitations.
    • Plugin: src/rpc/neovim-connection.ts
  • Renderer crash from retained tree-sitter nodes — getAllNodesOfType() collected Node objects during a tree walk and read them afterwards, and headingLevelFromNode() called node.child() on those retained nodes. A Node is a JavaScript object holding an address into WASM linear memory, so any parse that grows that memory replaces the backing buffer and leaves the retained node pointing into a detached one — a read at base + garbage, which segfaults the renderer with no JavaScript frame. Structural heading motions (]h, [h, level variants) now walk with a TreeCursor and extract plain data, retaining no node and deleting the cursor. Measured against an isolated reproducer: 8 of 16 runs before, 0 of 16 after; the full spec went from a pooled 29% to 0 of 16.
    • Plugin: src/treesitter/js-api.ts, src/motions/headings.ts
  • vim.treesitter.query.parse() leaked its compiled query — every call built a QueryWrapper that no cache owned, so NamedQueries.dispose() never saw it and the underlying WASM query survived for the life of the Lua state. Parsed queries are now tracked and deleted on state close alongside the named ones, through runCleanups so one failing disposer cannot skip the rest.
    • Plugin: src/lua/treesitter/query-api.ts
  • Query capture nodes carried no tree reference — Query:iter_captures() and Query:iter_matches() pushed nodes without a _tree field, so node:tree() returned nil and every node reached through :parent()/:child() lost the reference too, unlike Neovim. Both iterators now hold the source node’s tree table in the Lua registry and attach it to each capture, matching the existing iter_children shape and releasing the reference when the iterator is exhausted.
    • Plugin: src/lua/treesitter/query-api.ts
  • The 3-second deferred Neovim shutdown is removed — it existed only as a mitigation for the renderer crash resolved below, adopted on a comparison that is now known to have been confounded because both of its arms contained the leak. With the leak fixed, the reproducer spec measures 0 segfaults in 16 runs in all three teardown shapes — the deferral, the original synchronous qa!-and-wait that used to crash 24 of 46, and the immediate non-blocking SIGTERM that now ships. 48 runs, 0 segfaults. Teardown keeps the non-blocking shape, which is better on its own merits and is also less code than the synchronous version: disconnect returns immediately instead of blocking for up to four seconds, and SIGTERM lets Neovim exit on its own terms where qa! did not.
    • Plugin: src/rpc/neovim-connection.ts
  • Intermittent renderer crash when disconnecting the Neovim backend — resolved, and by a fix that had already shipped for an apparently unrelated defect. The cause was a leaked web-tree-sitter TreeCursor: getAllNodesOfType allocated one per structural motion and never deleted it, leaving it in the library’s FinalizationRegistry, which registers a cursor under its tree’s pointer. The CM6 bridge frees that tree on the next re-parse, so GC later fired the finalizer against a dangling pointer and corrupted the WASM allocator from a GC callback — which is why it required RPC traffic and bundled-fork editing in the same session. The reproducer spec that measured 24 of 46 runs now measures 0 segfaults in 16. The deferred-teardown mitigation is retained but is no longer load-bearing; the measurement that justified it was confounded, because both of its arms contained the leak.
    • Docs: KNOWN_LIMITATIONS.md, test/flaky-inventory.md
  • Two trees handed to Lua had no owner — vim.treesitter.get_string_parser() and TSTree:copy() each allocate a tree that no cache holds, so nothing freed them deterministically and their only reclaim was a GC finalizer at an unpredictable moment. That is the mechanism behind unpredictable TSNode staleness, and it is the same handle-ownership class as the renderer crash. Both are now tracked and deleted when the Lua state closes.
    • Plugin: src/lua/treesitter/api.ts, src/lua/treesitter/tree.ts
  • The Lua treesitter parser caches outlived the Lua state — parserCache and ltreeCache were module-level, so lua_close left both populated: a reloaded configuration inherited the previous state’s trees, and their WASM trees, parsers, and injection queries were never freed. Both are now per-state and disposed through registerStateCleanup, deleting cached trees and destroying cached LanguageTrees.
    • Plugin: src/lua/treesitter/api.ts

Tests

  • test/ now passes full strict type-checking — typecheck:tests runs tsc -p tsconfig.test.json unfiltered at zero errors, replacing the resolution-only allowlist (TS2304, TS2305, TS2307, TS2551, TS2552) that let nullability, argument-type, and missing-property errors through. The 19 errors the run reported against src/ were configuration gaps rather than defects and are resolved by including the existing ambient declarations; no src/ code changed. The pass surfaced four defects no other gate could see: a vacuous expect(result.flash).toBe(DEFAULT_SETTINGS.flash) comparing undefined to undefined against a setting that does not exist, a runCleanups() call missing its required context argument, a vimHandleKeys(string[]) call whose thrown TypeError was swallowed by the diagnostic’s own catch, and a Probe interface missing the two fields its assertions read.
    • Tests: tsconfig.test.json, package.json, test/unit/settings-resolution.test.ts, test/unit/lua/plugin-demand-harness.ts, test/specs/navigation.e2e.ts, test/specs/lua-plugin-flash-diagnostic.e2e.ts, and 42 further spec and unit files
  • The vim.treesitter e2e suite now tests something — all 12 of its assertions read getLuaConfigError(), which returned plugin.luaLoadResult.error. No such member exists on the plugin, so the helper returned null unconditionally and the whole suite passed against a Lua body of this is not valid lua @@@ ###; the vim.g.__* flags each test computed were never read. Each test now reads its flags back and asserts their values, and a config that fails part-way through drops the later flags rather than reporting nothing. Sabotaging the same test now fails it.
    • Tests: test/specs/treesitter.e2e.ts
  • Spec shape guards — test/unit/spec-shape-guards.test.ts closes the class strict typing cannot: a hand-written Record<string, T> plugin handle is an assertion about the plugin, so an invented member compiles and silently reads undefined. It cross-checks every declared plugin member in test/ — 97 spec files declare one — against the class in src/main.ts, and fails any executeObsidian callback whose { error: … } branch is cast to a primitive that drops it (109 such callbacks exist; none currently does). Both guards are negative-controlled.
    • Tests: test/unit/spec-shape-guards.test.ts
  • Root configuration files are type-checked — typecheck:config adds a seventh blocking gate over wdio.conf.mts, eslint.config.mts, vitest.config.ts, and the .mjs build, release and CI-reporting scripts, none of which sat inside either existing tsconfig include. wdio.conf.mts reported 22 errors on first measurement, including in the afterTest cleanup hook whose failure surfaces as cross-test flakiness rather than a red test. It needs moduleResolution: nodenext and type-only imports of webdriverio and wdio-obsidian-service, because a declare global augmentation only loads if its package is imported. The .mjs half reported 11 more: esbuild.config.mjs was clean, but report-e2e-failures.mjs — the script that reports CI failures — carried an implicit any[], four unchecked string | undefined arguments and an unguarded Map.get() dereference, and version-bump.mjs read process.env.npm_package_version without a guard, so running it outside npm version wrote a version-less manifest.json and a literal "undefined" key into versions.json. All three scripts were re-run against sample input to confirm unchanged output.
    • Tests: tsconfig.config.json, wdio.conf.mts, vitest.config.ts, esbuild.config.mjs, version-bump.mjs, scripts/report-e2e-failures.mjs, scripts/report-latency.mjs, package.json
  • M7 latency certification harness — test/specs/rpc-latency.e2e.ts measures real keydown to first rAF after the same CM6 transaction criterion for fork and production RPC over a 2,004-line runtime fixture (N=500 plus 75 warmups). It proves production fork interception and bridge engagement, enforces stable document size, and gates on the expected fork-faster p50 relationship. The certified run measured fork/RPC p50 15.3/17.3 ms, p95 39.5/36.2 ms, and p99 53.1/48.7 ms, for p95/p99 deltas of −3.3/−4.4 ms; delay, forced-layout, engagement, and drift controls are recorded in rpc-latency-negative-controls.md.
  • RPC prerequisite coverage — all 13 RPC specs use test/specs/rpc-prerequisites.ts to warn and skip when Neovim is absent, below API level 12, or a required fetched fixture is missing. Oil now checks its fetched flash fixture, and only the POSIX old-API stub scenario skips on Windows.
  • RPC lifecycle acceptance coverage — 7 WDIO scenarios cover attach/API reporting, runtime disable, Vim disable, unexpected SIGKILL and reconnect, plugin unload, missing binaries, and the API-level floor. PID liveness is checked directly, teardown/version-floor sabotages are recorded in test/specs/rpc-lifecycle.negative-control.md, and the settings-option inventory records both controls as process-backend settings rather than Vim options. Disposable .sisyphus/ spike files are excluded from the production lint project.
  • RPC text synchronisation acceptance coverage — test/specs/rpc-text-sync.e2e.ts checks isolated fixture loading, normal Markdown buffer identity, 210 boundary-valid API edits, cross-line collapse, end/start deletion, append, and the documented invalid UTF-8 boundary divergence against a raw-byte Lua oracle. Its active-leaf regression uses two known file contents, switches both ways, retains a Neovim key edit, and independently reads both files through the vault adapter. Restoring the one-shot seed reproduced Target.md as A original\nA second line on first activation and rpc-A original\nA second line after the later switch, including on disk.
  • RPC key delegation acceptance coverage — test/specs/rpc-keys.e2e.ts drives 210 insert/operator/visual/count/dot-repeat/undo-redo/macro sequences containing ASCII, astral, combining, and CJK text through the editor DOM, compares ciwfoo<Esc>w. with live headless Neovim, checks bridged/unbridged buffer/cursor/mode/register identity, proves single insertion, and records the visible/source D7 measurement. Negative controls produced xxseed without fork interception, xxiseed without preventDefault, CM column 0 instead of 6 without cursor sync, and buffer/register divergence after a bridged-only x.
  • RPC frontmatter acceptance coverage — six M2c scenarios in test/specs/rpc-keys.e2e.ts cover visible-mode connection, repeated upward motion, frontmatter-preserving dd with an independent vault read, guarded gg, Source-mode navigation, and API edits inside the fold. test/specs/rpc-keys-negative-controls.md records the no-fold, no-guard, and Source-fold sabotages and exact cursor/document outcomes.
  • RPC decoration acceptance coverage — test/specs/rpc-decorations.e2e.ts compares every rendered flash.nvim label against flash’s own all-namespace extmark query, verifies label selection in Neovim and CM6, statically excludes polling and grid consumption, and proves the idle bridge is empty. Negative controls observed 39/40 labels after dropping one forwarded extmark, all 40 offsets shifted by one after a byte-column error, and 0/40 decorations without the UI redraw clock.
  • RPC write/read acceptance coverage — test/specs/rpc-write-routing.e2e.ts verifies Obsidian’s save command was invoked, reads saved and deliberately stale content through the vault adapter, checks :e! preserves the unsaved editor document, and rejects a stale Neovim modified flag. test/specs/rpc-write-routing-negative-controls.md records the four required sabotages and observed values.
  • RPC Obsidian bridge acceptance coverage — test/specs/rpc-obsidian-bridge.e2e.ts covers seven representative host scenarios plus teardown-aware refresh. test/specs/rpc-obsidian-bridge-negative-controls.md records isolated missing-map, no-op-dispatch, unguarded-abbreviation, and skipped-teardown failures.
  • M4b registry and workspace bridge coverage — test/unit/vim-registration-inventory.test.ts guards the measured 75-motion, 78-action, 164-map, and 102-ex registration surface and six M4a selections. The RPC bridge spec adds horizontal split, four-way pane focus, counted and explicit tab targeting, lowercase command/abbreviation safety, wikilink definition navigation, and expanded refresh inventory coverage.
  • M4b Batch 2 picker bridge coverage — the RPC bridge spec asserts every picker leader source, lowercase :buffers, query-bearing :grep, named :Picker source selection, modal-key file selection plus post-selection Neovim content, guarded substitution behavior, and duplicate-free refresh. Four subject sabotages prove query, mapping, guard, and re-seed assertions fail independently.
  • M4b Batch 3 Harpoon, marks, and jumplist coverage — the RPC bridge spec asserts slot mappings and arguments, optional versus explicit removal, three-file cursor-preserving cycling, cross-note and counted older jumps with independent file/cursor/content checks, :jumps, mark-table plus sign-column deletion, substitution safety, and duplicate-free refresh. Four subject sabotages prove slot, host ownership, count, and gutter refresh independently.
  • M4b Batch 4 Oil isolation coverage — test/specs/rpc-oil.e2e.ts drives all 16 Oil mappings through the embedded editor’s real DOM with RPC connected, verifies 14 observable host effects, explicitly skips the two OS-shelling actions, checks handler/interception state on Oil and Markdown, and proves Oil keys do not change Neovim’s buffer. Forced interception removed the first character from Neovim’s sentinel, while no-op’ing oilHelp failed only its scenario.
  • M4b Batch 5 folding and undo-tree coverage — test/specs/rpc-folds-undo.e2e.ts covers nine scenarios for the fold mirror, native fold motions/operator, raw-byte undo/redo and chronological navigation, native :earlier/:later, Neovim-backed sidebar data, and duplicate-free bridge refresh. Four subject sabotages prove fold forwarding, row mapping, sidebar data ownership, and command lifecycle independently.
  • M5a structural navigation and hard-wrap coverage — test/specs/rpc-structural-nav.e2e.ts runs 14 fork-oracle parity scenarios across headings, levels, counts, list items, links, gq/gw, and seven operator-pending forms. It compares yank contents, rejects unexpected empty documents, and independently reads one edited note through the vault adapter. Five subject sabotages are recorded in rpc-structural-nav-negative-controls.md.
  • M5b Markdown text-object coverage — test/specs/rpc-text-objects.e2e.ts runs 65 fork-oracle parity scenarios covering delete, change, yank/register, visual selection, and counted forms for 13 Markdown object shapes, including nested and adjacent delimiters. Every operator checks the document-wipe invariant, one edit is read through the vault adapter, and three subject sabotages are recorded in rpc-text-objects-negative-controls.md.
  • M6a floating-window coverage — test/specs/rpc-floats.e2e.ts compares flash.nvim’s prompt content and config-derived placement with Neovim, checks two-float z-index order, close and disconnect cleanup, and a custom float’s extmark and border. rpc-floats-negative-controls.md records missing-forwarding, one-cell offset, ignored-z-index, and stale-close failures.
  • M6b IME composition coverage — test/specs/rpc-ime.e2e.ts drives Chromium’s native composition path through CDP, verifies byte-exact CJK commit, dot-repeat, cancellation, key suppression, and active-note switching with an independent vault-adapter read. rpc-ime-negative-controls.md records CM6-only commit, buffer-API commit, and composing-key forwarding failures.
  • M8a external-UI message coverage — test/specs/rpc-messages.e2e.ts covers eight scenarios for informational and error Notices, real-key Lua errors, silent undo/search kinds, one-per-message dispatch, duplicate limiting, and 200-key grid-event latency. rpc-messages-negative-controls.md records missing-dispatch, noisy-kind, and removed-dedup failures with observed counts and values.
  • M8b external command-line coverage — test/specs/rpc-cmdline.e2e.ts drives all 11 command-line, caret, prefix, prompt, selection, cancellation, nesting, and bundled-fork-isolation scenarios through real editor key events. rpc-cmdline-negative-controls.md records stale-hide, raw-byte-caret, and single-level-state failures with observed counts and values.
  • M8c popup-menu and M8d status-mode coverage — test/specs/rpc-popupmenu.e2e.ts drives insert completion and command-line wildmenu selection/hide through real editor key events, while four lifecycle scenarios cover insert, normal, visual-line, and disconnect arbitration. rpc-popupmenu-negative-controls.md records ignored-selection, wrong-anchor, and suppressed-mode-handler failures with observed counts and values.
  • The treesitter-handle-leak rule’s own behaviour is locked by a test — test/unit/treesitter-handle-leak-rule.test.ts runs the rule against nine fixtures and asserts exactly which fire: all five leak shapes, none of the four correctly-freed ones, plus the shipped getAllNodesOfType defect. Negative-controlled by restoring the stopBy: end that produced the original false positive, which reports [ 'reassign-both.ts' ] against an expected []. A blocking gate that flags correct code gets disabled, so the silent cases matter as much as the firing ones.
  • Unowned treesitter handle coverage — test/unit/lua/treesitter-queries.test.ts asserts that a get_string_parser() tree and a TSTree:copy() are both deleted when the Lua state closes. Negative-controlled against the unowned state: 3 deletes with the owner installed, 1 without, the survivor being the document parser’s cached tree.
  • Treesitter Lua memory-safety coverage — test/unit/lua/treesitter-queries.test.ts adds three regressions against the real bundled grammars: query.parse() wrappers are deleted on state close, capture nodes from both iterators expose their tree, and the parser cache is freed on close without leaking into the next state. Each was negative-controlled against the defect it replaces — 0 deletes instead of 3, iter_captures node has no tree and iter_matches node has no tree, and 1 delete instead of 2.
  • Undo-tree navigation is asserted behaviourally — the existing g-/g+ scenarios asserted only that the keys did not crash and left the mode alone, both of which held for the entire time g- was broken. test/specs/undo-tree.e2e.ts now asserts that the live tree’s current sequence moves, which fails against the previous code with the sequence unchanged at 20 instead of 19 while the two non-crash scenarios still pass.
  • The pluginAutoFetch boundary is now held by a test rather than by an argument — the M0 policy determination rests on that fetch path never feeding the real Neovim runtime, since installing executable dependencies for a runtime is listed under Not allowed in the Developer Policies, and design risk R-5 required a test asserting it cannot. test/unit/rpc/plugin-autofetch-boundary.test.ts asserts the complete spawn argv, so any added runtimepath entry fails it, and separately asserts that no file under src/rpc/ imports the fetch or store modules or names their on-disk paths, which it reads from plugin-store.ts rather than restating. Negative-controlled twice: prepending lua/.staging to the runtimepath failed three of the five assertions, reporting a 7-element argv against the expected 5; adding a plugin-store import to decorations.ts failed exactly one, reporting [ 'decorations.ts -> plugin-store' ] against [].
    • Tests: test/unit/rpc/plugin-autofetch-boundary.test.ts, src/rpc/neovim-connection.ts
  • Unit tests can now import any RPC module — anything reaching src/rpc/companion.lua failed to load under Vitest, which parsed the Lua as JavaScript, so no test under test/unit/rpc/ could import neovim-connection.ts at all. A luaTextPlugin() mirrors esbuild’s '.lua': 'text' loader, alongside the existing .wasm precedent.
    • Tests: test/helpers.ts, vitest.config.ts
  • Table navigation under the Neovim backend is covered — test/specs/rpc-table-nav.e2e.ts asserts the overlay activates while RPC is connected, that l moves the highlighted cell, and that neither Neovim’s cursor nor the buffer changes. The two absence assertions carry a positive control rather than a sabotage: a sibling scenario presses the same key outside the table and requires the same probe to report movement, so “Neovim did not move” cannot hold for every implementation. Negative-controlled by forcing canActivate() false, which reported entered: false, navigated: false against true, true.
    • Tests: test/specs/rpc-table-nav.e2e.ts
  • Companion-label and leader-group coverage — six unit cases cover leaderGroupPrefixes and leaderGroupLabel, and two live scenarios assert the companion’s operator-pending maps read as prose and that every registered group starts with the leader without ever equalling it. Negative-controlled: starting the prefix loop at leader.length instead of leader.length + 1 failed three unit cases including the leader-exclusion one; removing the install call failed the live group scenario; renaming the group-label key failed the label case.
    • Tests: test/unit/rpc/bridge-action-label.test.ts, test/specs/rpc-obsidian-bridge.e2e.ts
  • Hint-mode and bridged-label coverage — test/unit/rpc/bridge-action-label.test.ts covers the label derivation across 13 names; two scenarios in rpc-obsidian-bridge.e2e.ts activate hint mode from Neovim and assert the buffer is byte-identical afterwards, and that no generated desc leaks an internal id. The buffer assertion is the load-bearing one — the overlay closing would pass even if the label key had also reached Neovim. Negative-controlled: removing hintactivate from the allowlist failed only the hint scenario; reverting DESC_PREFIX to the old id form failed three, including the refresh-inventory check. The refresh check now identifies leader mappings by their leader prefix rather than by desc, since the description no longer encodes an action name.
    • Tests: test/unit/rpc/bridge-action-label.test.ts, test/specs/rpc-obsidian-bridge.e2e.ts
  • External mode coverage — test/unit/vim/external-mode.test.ts covers the Neovim mode() mapping across 19 values plus the publish, dedup and clear semantics, and asserts the precedence rule directly rather than mirroring it. Two lifecycle scenarios drive real keys against a live Neovim for insert, normal, visual and visual-line, and check the seam clears on disconnect. Negative-controlled four ways: testing no after n failed the three operator-pending cases; removing the unchanged-mode dedup failed exactly one; dropping the visual-block mapping failed three; reversing ?? in the precedence helper failed two; and removing the per-key publish failed both e2e scenarios.
    • Tests: test/unit/vim/external-mode.test.ts, test/specs/rpc-lifecycle.e2e.ts
  • A bundled snippet was referenced with mixed path separators on Windows — the relative path is built with a literal / and was then joined to the configuration directory with the platform separator, so the file was written to …\lua\vim-motions-snippets\global.json and referenced as …\lua\vim-motions-snippets/global.json. Windows opens either spelling, so nothing failed at runtime and the snippet still expanded; the only symptom was two spellings of one path. Relative segments are now split on either separator before rejoining, and a unit case pins it so the mismatch fails on every platform rather than on a Windows shard.
    • Plugin: src/rpc/config-export.ts
  • A Windows-only assertion failure in the snippet-export spec — the scenario compared the generated configuration against a raw filesystem path, but the path is embedded in a Lua string literal, so on Windows every separator is doubled and D:\a\… never appears verbatim. It passed on Linux and macOS and failed one Windows shard. The product was correct throughout, which the sibling scenario proved by expanding a snippet through LuaSnip on the same runner; only the assertion was wrong. It now unescapes the literals before comparing, and a platform-independent unit case pins the round-trip so the next regression of this class fails everywhere rather than on one shard.
    • Tests: test/specs/rpc-config-export.e2e.ts, test/unit/rpc/config-export.test.ts
  • Snippet expansion is covered end to end under the Neovim backend — LuaSnip joins test/fixtures/test-plugins.json pinned at 0abc8f39, which makes it require-able from a live Neovim through the same runtimepath append that already serves flash.nvim to the decoration and float oracles. The scenario drives the production path rather than the API: real keys into the Obsidian editor, forwarded to Neovim, expanded by LuaSnip through the generated configuration’s own <Tab> mapping, and mirrored back. It skips with a message when the fixture is absent. Removing the expansion key from the generated block fails it, along with the two scenarios that load the configuration.
    • Tests: test/fixtures/test-plugins.json, test/specs/rpc-config-export.e2e.ts, .gitignore
  • Consent-modal and auto-regeneration coverage — three scenarios in rpc-config-export.e2e.ts drive the real settings UI: the preview lists repositories and the configuration before anything happens and cancelling writes nothing, auto-regeneration rewrites on a covered change only while the toggle is on, and a hand-edited file survives even with it on. First e2e in the suite to open the settings tab; post-1.13 renders the seven pages as navigable rows, so General has to be opened before the controls exist in the DOM, and the settings pane is itself a modal, so selecting “the first .modal” finds it rather than the preview. Negative-controlled three ways: forcing the auto-refresh gate open failed only the toggle scenario, neutering the sentinel failed both hand-edited-file scenarios, and making Cancel write failed the consent scenario. The first attempt at that last control had Cancel call the full install path and did not fail, because the write lands long after any window a test can wait on — the assertion covers the write rather than the install, and says so.
    • Tests: test/specs/rpc-config-export.e2e.ts
  • Snippet-export coverage — unit cases assert the block emits the paths it was given and registers an expansion key, and a live scenario asserts the bundled files are written where the generated config says they are. The expansion-key control initially did not fire: the assertion looked for expand_or_jumpable, which survives on the following line when the vim.keymap.set call is removed, so it was strengthened to assert the mapping itself. Removing the bundled write failed only the live scenario.
    • Tests: test/unit/rpc/config-export.test.ts, test/specs/rpc-config-export.e2e.ts
  • Package-activation and install-list coverage — rpc-config-export.e2e.ts asserts the real spawn leaves Neovim able to activate a package, since the --clean failure looks like success and cannot be caught by inspecting a hand-built command line; removing the packpath restore fails it. Unit cases cover the repository list, including that flash is not requested twice when both features that use it are on. The R-5 argv assertion caught the new spawn element exactly as designed, and rather than bumping the expectation it now also asserts the added path resolves inside Neovim’s own data directory, which is what keeps it clear of the vault fetch store.
    • Tests: test/unit/rpc/plugin-autofetch-boundary.test.ts, test/unit/rpc/config-export.test.ts, test/specs/rpc-config-export.e2e.ts
  • Configuration-export coverage — test/unit/rpc/config-export.test.ts holds generation, the sentinel, the fingerprint and the module probe across 17 cases; test/specs/rpc-config-export.e2e.ts proves the written file is requireable by a live Neovim rather than inspecting the path we chose. Negative-controlled five ways: removing the sentinel check failed only the overwrite-refusal case; dropping the pcall guard failed two generation cases; making the fingerprint constant failed staleness; and dropping the lua/ segment failed three e2e cases. The fourth control initially did not fire at all, because resolveGeneratedConfigPath has two branches and the first sabotage hit the one the spec does not take — the control is what caught that, not review.
    • Tests: test/unit/rpc/config-export.test.ts, test/specs/rpc-config-export.e2e.ts, test/unit/known-set-options.test.ts
  • Editor-option projection coverage — test/specs/rpc-editor-options.e2e.ts drives five scenarios through real editor keydowns rather than nvim_input, covering bullet continuation on and off, yank highlight on and off, and reapplication after an activation reseeds the buffer. Negative-controlled three ways: restoring the ftplugin’s f flag produced hello against - hello; removing the o/O mappings produced the same; suppressing the yank notification failed only the highlight scenario; and dropping the reseed call produced hello with no marker while the other four passed. A fourth control was not needed — rpc-structural-nav already failed on the first implementation, reporting gq output re-bulleted as - to wrap with a hanging indent instead of the hanging-indent form.
    • Tests: test/specs/rpc-editor-options.e2e.ts
  • Generic Obsidian-command coverage — test/specs/rpc-obsidian-bridge.e2e.ts runs :ob workspace:toggle-pin through Neovim and asserts the leaf pins and unpins, having first confirmed the id is in Obsidian’s registry and that no bridge-installed command could account for the effect. A second scenario holds the abbreviation guard. Negative-controlled in both halves: dropping 'ob' from the allowlist failed only that scenario with obInstalled: false against true, and neutering executeCommand failed it again at the pin wait while the guard scenario still passed. The bridge-installed probe is scoped by the vim-motions-rpc: marker because matching every Neovim user command picked up the developer config’s DapStepInto, which lowercases to contain pin.
    • Tests: test/specs/rpc-obsidian-bridge.e2e.ts

Documentation

  • README.md, docs/configuration/settings.md: desktop/arbitrary-code/FFI/external-file/no-sandbox/no-install disclosure and Milestone 1 scope.
  • KNOWN_LIMITATIONS.md: current lifecycle-only boundary and separation from fengari plugin auto-fetching.
  • AGENTS.md, CONTRIBUTING.md: RPC source ownership and lifecycle test locations.
  • README.md, KNOWN_LIMITATIONS.md, docs/features/neovim-backend.md: M8a message routing, duplicate limiting, and deferred command-line/popup rendering.
  • AGENTS.md, CONTRIBUTING.md: M8a redraw/message source ownership and acceptance/negative-control locations.
  • README.md, KNOWN_LIMITATIONS.md, docs/features/neovim-backend.md: M8b command-line, prompt, byte-caret, nested-level behavior, and deferred popup-menu boundary.
  • AGENTS.md, CONTRIBUTING.md: M8b command-line source ownership and acceptance/negative-control locations.
  • README.md, KNOWN_LIMITATIONS.md, docs/features/neovim-backend.md: M8c popup-menu completion and M8d Neovim-owned status-bar mode behavior.
  • AGENTS.md, CONTRIBUTING.md: popup-menu/mode-status source ownership and acceptance/negative-control locations.
  • CHANGELOG.md: M8c/M8d implementation, tests, and documentation coverage.
  • CHANGELOG.md: Milestone 1 implementation and test coverage.
  • README.md, docs/configuration/settings.md, KNOWN_LIMITATIONS.md: Milestone 2a text scope, active-editor/single-buffer boundary, and deferred key/frontmatter/decorations work.
  • AGENTS.md, CONTRIBUTING.md: document-sync source ownership and text-sync acceptance/negative-control locations.
  • test/specs/rpc-text-sync-negative-controls.md: observed line-event, offset, trailing-newline, decoded-oracle, and intro-buffer identity failures.
  • CHANGELOG.md: Milestone 2a implementation and test coverage.
  • README.md, docs/configuration/settings.md, KNOWN_LIMITATIONS.md: explicit Neovim configuration selection, isolated startup behavior, production default, and measured startup benefit.
  • AGENTS.md, CONTRIBUTING.md: fixture-backed RPC test contract and configuration-aware connection ownership.
  • README.md, docs/configuration/settings.md, KNOWN_LIMITATIONS.md: Milestone 2b key ownership, Source-frontmatter requirement, cursor/mode synchronization, and deferred IME/decorations scope.
  • AGENTS.md, CONTRIBUTING.md: key-delegation source ownership and RPC key acceptance coverage.
  • .sisyphus/plans/neovim-rpc-backend-design.md: D7 decision and measured visible/source cursor behavior.
  • CHANGELOG.md: Milestone 2b implementation, tests, and negative controls.
  • KNOWN_LIMITATIONS.md, .sisyphus/plans/neovim-rpc-backend-design.md: corrected per-activation seeding semantics and explicit prohibition on writing a stale mirror into another note.
  • AGENTS.md, CONTRIBUTING.md: document-sync ownership and the independent two-file disk-integrity regression.
  • test/specs/rpc-text-sync-negative-controls.md: exact editor and on-disk values from restoring the cross-note overwrite defect.
  • CHANGELOG.md: cross-note data-loss fix and negative-control evidence.
  • README.md, docs/configuration/settings.md, KNOWN_LIMITATIONS.md: Milestone 2c support for both properties modes and rendered-frontmatter behavior.
  • AGENTS.md, CONTRIBUTING.md: shared delimiter, RPC fold ownership, cursor guard, widget-focus exclusion, and acceptance coverage.
  • test/specs/rpc-keys-negative-controls.md: M2c fold, guard, and Source-mode negative-control evidence.
  • CHANGELOG.md: Milestone 2c implementation, tests, and documentation.
  • README.md, docs/configuration/settings.md, KNOWN_LIMITATIONS.md: Milestone 3 decoration scope, persistent-extmark limits, redraw clock, and remaining float/IME boundaries.
  • AGENTS.md, CONTRIBUTING.md: bundled companion/decorations ownership and RPC decoration acceptance coverage.
  • .sisyphus/plans/neovim-rpc-backend-design.md: resolved D-E companion packaging decision.
  • test/specs/rpc-decorations-negative-controls.md: exact missing-label, shifted-offset, missing-redraw-clock, and assertion-reachability failures.
  • CHANGELOG.md: Milestone 3 implementation, tests, and negative controls.
  • README.md, docs/configuration/settings.md, KNOWN_LIMITATIONS.md: acwrite save ownership and Obsidian-backed reload semantics.
  • AGENTS.md, CONTRIBUTING.md: companion/document-sync ownership and write/read acceptance coverage.
  • .sisyphus/plans/neovim-rpc-backend-design.md: deferred M2a write/read item completion and verification evidence.
  • test/specs/rpc-write-routing-negative-controls.md: exact missing-autocmd, missing-acwrite, stale-disk, and dirty-flag failures.
  • CHANGELOG.md: write/read routing implementation, tests, and documentation.
  • README.md, docs/configuration/settings.md, KNOWN_LIMITATIONS.md: M4a representative surface, guarded lowercase commands, and remaining M4b boundary.
  • AGENTS.md, CONTRIBUTING.md: feature-bridge source ownership and acceptance/negative-control locations.
  • test/specs/rpc-obsidian-bridge-negative-controls.md: exact M4a mapping, dispatch, abbreviation, and teardown sabotage outcomes.
  • CHANGELOG.md: M4a implementation, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/features/workspace-navigation.md: M4b Batch 1 workspace/navigation scope, parameter forwarding, and remaining batches.
  • AGENTS.md, CONTRIBUTING.md: expanded bridge ownership and registry inventory/RPC acceptance coverage.
  • test/specs/rpc-obsidian-bridge-negative-controls.md: exact M4b count, pane-map, abbreviation-guard, and refresh-teardown failures.
  • .sisyphus/plans/neovim-rpc-backend-design.md: M4b Batch 1 implementation and verification result.
  • CHANGELOG.md: M4b Batch 1 implementation, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/features/ex-commands.md: M4b Batch 2 picker scope, argument forwarding, modal ownership, and remaining batches.
  • AGENTS.md, CONTRIBUTING.md: expanded RPC picker acceptance coverage.
  • test/specs/rpc-obsidian-bridge-negative-controls.md: exact Batch 2 query, mapping, abbreviation, and re-seed sabotage outcomes.
  • .sisyphus/plans/neovim-rpc-backend-design.md: M4b Batch 2 implementation and empirical result.
  • CHANGELOG.md: M4b Batch 2 implementation, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/features/harpoon.md, docs/features/workspace-navigation.md: M4b Batch 3 scope, host-owned cross-note jumplist, cursor restoration, and uppercase cross-file mark gap.
  • AGENTS.md, CONTRIBUTING.md: expanded feature-bridge ownership and RPC acceptance coverage.
  • test/specs/rpc-obsidian-bridge-negative-controls.md: exact Batch 3 slot, jumplist ownership/count, and gutter-refresh failures.
  • .sisyphus/plans/neovim-rpc-backend-design.md: M4b Batch 3 implementation and explicit marks/jumplist ownership decision.
  • CHANGELOG.md: M4b Batch 3 implementation, fix, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/features/oil-explorer.md: M4b Batch 4 native Oil ownership, RPC exclusion, and path-register behavior.
  • AGENTS.md, CONTRIBUTING.md: RPC Oil acceptance and negative-control locations.
  • test/specs/rpc-oil-negative-controls.md: exact forced-interception corruption and isolated action failure.
  • .sisyphus/plans/neovim-rpc-backend-design.md: corrected Class-B total and empirical Oil result.
  • CHANGELOG.md: native Oil fixes, RPC isolation tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/features/workspace-navigation.md, docs/features/undo-tree.md: Batch 5 fold/undo ownership, rendering, sidebar data, and fold-persistence boundary.
  • AGENTS.md, CONTRIBUTING.md: RPC fold/undo source ownership and acceptance/negative-control locations.
  • test/specs/rpc-folds-undo-negative-controls.md: exact forwarding, row-mapping, sidebar-source, and missing-command failures.
  • .sisyphus/plans/neovim-rpc-backend-design.md: corrected Class-B total from 121 to 80 and per-endpoint Batch 5 disposition.
  • CHANGELOG.md: Batch 5 implementation, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/reference/keybindings.md, docs/features/structural-navigation.md, docs/features/hardwrap.md: M5a native/ported ownership, aliases, operator parity, and textwidth wiring.
  • AGENTS.md, CONTRIBUTING.md: M5a RPC acceptance and negative-control locations.
  • test/specs/rpc-structural-nav-negative-controls.md: exact missing-map, missing-width, level, motion-kind, and count failures.
  • CHANGELOG.md: M5a implementation, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/reference/keybindings.md, docs/features/text-objects.md: M5b native/ported ownership, supported object list, bounded ranges, and the unparsed highlight-delimiter gap.
  • AGENTS.md, CONTRIBUTING.md: M5b RPC acceptance and negative-control locations.
  • test/specs/rpc-text-objects-negative-controls.md: exact range-end, missing-map, and count-forwarding failures.
  • CHANGELOG.md: M5b implementation, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/configuration/settings.md: M6a float support, approximate CM6 cell mapping, relative origins, and remaining IME boundary.
  • AGENTS.md, CONTRIBUTING.md: floating-window source ownership and RPC acceptance/negative-control locations.
  • test/specs/rpc-floats-negative-controls.md: exact forwarding, position, z-index, and stale-close sabotage outcomes.
  • .sisyphus/plans/neovim-rpc-backend-design.md: M6a implementation and empirical result.
  • CHANGELOG.md: M6a implementation, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md, docs/configuration/settings.md: M6b composition ownership, commit path, cancellation lifecycle, and remaining RPC boundaries.
  • AGENTS.md, CONTRIBUTING.md: IME input source ownership and CDP acceptance/negative-control locations.
  • test/specs/rpc-ime-negative-controls.md: exact CM6-only, buffer-API, and composing-key forwarding sabotage outcomes.
  • .sisyphus/plans/neovim-rpc-backend-design.md: M6b implementation and empirical result.
  • CHANGELOG.md: M6b implementation, tests, negative controls, and documentation.
  • README.md, KNOWN_LIMITATIONS.md: M7’s certified latency measurements and deltas.
  • AGENTS.md, CONTRIBUTING.md: production latency harness, p50 sanity gate, negative-control locations, and cross-platform RPC test tooling.
  • .sisyphus/plans/neovim-rpc-backend-design.md: M7 implementation, fold-expression repair, measurements, controls, and certification verdict.
  • test/specs/rpc-latency-negative-controls.md: exact delay, layout, engagement/isolation, size-drift, and sanity-gate output.
  • CHANGELOG.md: M7 harness, fold-expression performance repair, negative controls, and documentation.
  • docs/features/neovim-backend.md, docs/features/index.md: opt-in Neovim backend setup, security and ownership boundaries, shared keybindings, and limitations.
  • docs/configuration/settings.md: removes the stale milestone scope label.
  • README.md, KNOWN_LIMITATIONS.md: removes stale milestone wording while preserving the certified M7 measurements.
  • AGENTS.md, CONTRIBUTING.md: source-tree gaps, pinned installer/workflow ownership, RPC prerequisite guard, and current latency gate.
  • CHANGELOG.md: cross-platform CI provisioning, shared skip behavior, Windows scenario scope, and documentation updates.
  • test/flaky-inventory.md: the WASM heap-move mechanism is refuted by measurement — 84 probe readings across master and the reverted retaining walk, including failing runs, all report zero heap growth. Pre-growing the heap is dropped, and the four places that stated the mechanism as established are corrected. The retained-node fix and its measurements stand; only the stated reason was wrong.
  • KNOWN_LIMITATIONS.md: Lua TSNode handles become stale after a re-parse, and lifetime management is declined rather than deferred. Also records that dropping the delete() calls is not a safe alternative, because web-tree-sitter’s own FinalizationRegistry still frees the handle at a GC-determined moment.
  • test/flaky-inventory.md: the renderer segfault’s root cause, established by a 2x2. The fix changed two things — it stopped retaining Nodes and added the missing cursor.delete() — and only the second mattered: the leak alone measured 5 of 8 container runs with no nodes retained, while the original retaining walk with the cursor freed measured 0 of 6. A leaked TreeCursor stays in web-tree-sitter’s FinalizationRegistry holding its tree’s pointer, so GC later frees a tree the bridge already deleted. This also invalidates the arm that retired the use-after-free class: neutralising the twelve in-repo delete() calls never disabled the registry, so “nothing is ever freed” was never achieved.
  • docs/features/neovim-backend.md, KNOWN_LIMITATIONS.md: the Neovim plugin-compatibility boundary is published, closing design risk R-6. Compatibility is decided by mechanism rather than by plugin name: extmarks, virtual text, and floating windows cross, while screenpos() and the screen-cell query class are unbridgeable by construction — the bridge transports buffer coordinates, grid events are discarded, and proportional Markdown typography has no stable cell grid to answer with. matchadd() is unreachable separately, being window-local rather than an extmark. flash.nvim is named as the measured bridged case rather than presented as a certification of any plugin.
  • docs/features/neovim-backend.md, KNOWN_LIMITATIONS.md, README.md: the plugin’s own Lua and vimrc config does not bind editor keys while the Neovim backend owns them. .obsidian.init.lua still loads, but a vim.keymap.set there targets the bundled fork, which key delegation stands down, so editor mappings never fire and their equivalents belong in the user’s init.lua. Settings and host-rendered features are unaffected, and the bridge’s generated Obsidian action mappings follow the configured leader but not arbitrary remaps.
  • docs/features/neovim-backend.md, KNOWN_LIMITATIONS.md: a What changes in RPC mode section records which bundled-engine features Neovim replaces, which need a Neovim equivalent, and which host features read the fork’s event stream rather than bridge state. The Class-A disposition was a design decision but had never been written down for users, and the host-feature group was assumed unaffected because it only renders: which-key and hint mode subscribe to the adapter’s vim-keypress/vim-command-done, yank highlight to vim-yank, input-method switching to vim-mode-change, and the animated cursor resolves its per-mode shape from adapter.state.vim, so the shape stays on normal while Neovim is in insert. Measured on 0.12.5 rather than assumed: gr is unmapped, and o on - item one yields hello because the stock Markdown ftplugin sets formatoptions=jtcqln. Corrects a claim added earlier in this same section, which listed snippets, which-key, and the animated cursor among features that still apply — Tab is forwarded to Neovim before CM6’s snippet keymap sees it.
  • AGENTS.md: re-measures the es2021 build-target rationale, which was argued against the mobile floor “at 1.7.2” and so no longer matched the manifest. The conclusion is unchanged and the basis is now correct: Android 5.1 is the binding constraint and has not moved — still the Play Store minimum at 1.8.10 and at 1.13.6 — so raising minAppVersion does not raise it, while iOS went 13 → 14.5 → 15. At es2022 esbuild emits 8 static {} blocks where es2021 emits none, and those need Safari 16.4, above iOS 15 and far above the Android 5.1 WebView. The saving is 402 bytes gzipped of 729,321, not the 31 recorded; the bundle has grown since. The claims about native class fields and #private methods are dropped as unevidenced — #private occurs identically in both builds and no source file uses it.
  • README.md, docs/getting-started/index.md: state the 1.8.7 minimum, which the manifest had already been raised to.
  • docs/configuration/settings.md: adds the Grep binary mode and Picker keymap rows, which had no documentation at all, and corrects the claim that cursor shapes require the bundled fork.
  • docs/configuration/status-bar.md, docs/getting-started/recommended-setup.md: the extended mode indicators and per-mode cursor shapes were listed as fork-only; both are also provided by the Neovim backend, and only built-in vim mode lacks them.
  • docs/index.md: adds the tags frontmatter every other page carries.
  • KNOWN_LIMITATIONS.md: the async-Lua row and the require() status both still said “deferred” long after src/lua/coroutine-runner.ts and src/lua/module-snapshot.ts shipped.
  • CONTRIBUTING.md: records the two new modules in its source-tree list, which mirrors AGENTS.md and had been updated only there.
  • docs/configuration/vimrc.md: the guicursor example was annotated “bundled fork mode only”, which stopped being true once per-mode cursor shapes started following the backend’s mode.
  • KNOWN_LIMITATIONS.md: the Lua TextYankPost autocmd limitation now records that it also does not fire while the Neovim backend owns keys — the event comes from the bundled engine, which is stood down — and that the yank highlight feature is unaffected, being driven by a Neovim notification instead. Also adds the two RPC behaviours verified by hand rather than in CI, with the reason and the check for each.
  • AGENTS.md: records src/rpc/config-export.ts, src/vim/external-mode.ts, and the three new RPC specs, which the source-tree and test-organisation sections had not been updated for.
  • docs/configuration/settings.md: adds the Set up Neovim and Regenerate automatically rows, and points the Neovim backend note at the RPC-mode feature table.
  • docs/features/quality-of-life.md, docs/features/tables.md: corrects two qualifications that predate the Neovim backend. Yank highlight was documented as requiring the bundled fork engine, which is now misleading — it works under the backend too, where the fork is stood down and a TextYankPost notification drives the same renderer. Table-nav’s “requires the bundled vim engine” was accurate about built-in vim but read as excluding the backend, under which it is measured working.
  • docs/features/neovim-backend.md: removes a table left over from before the configuration export existed, which duplicated four rows of the generated set and listed snippets beside them — implying snippets were generated when no such block exists, and none is cheap, because the bundled snippets are vault-stored JSON and Lua-DSL definitions rather than a setup() call. Adds a warning that the generated file is a starting point rather than parity: dial.nvim’s default augends do not cover hex colours, booleans, dates or checkboxes, and nvim-surround’s defaults omit dsf/csf and insert-mode <C-G>s.
  • README.md: the third-party Lua compatibility paragraph is scoped to the bundled fengari runtime. It described the shim while reading as a statement about the plugin, which understated the Neovim backend in particular — that runs the user’s own Neovim, where LuaJIT FFI is available and flash.nvim works.

[0.150.0] - 2026-09-10

Added

  • Open configuration directory in system explorer command — reveals the folder containing the active init.lua / .obsidian.vimrc in the OS file manager, with the configuration file itself selected. That folder is the one require() searches for a lua/ directory, so it is the folder to manage modules from. Obsidian already exposes this as the unofficial-but-typed App.showInFolder(); no new Electron dependency is introduced for vault-relative configurations, and out-of-vault ones reuse the routing added below. Desktop only, and the command is hidden on mobile. When a Lua and a vimrc configuration share a folder, it is revealed once rather than twice. (#182)
    • Plugin: src/main.ts (open-configuration-directory), src/util/open-path.ts (revealPathInSystemExplorer, parentDirOf), src/util/external-fs.ts (revealExternalPath)
  • cursorlineopt accepts Neovim’s full grammar, including screenline — the option was a three-value enum (number/line/both). It now parses the real comma-separated list over line, screenline, number and both, in any order, with both as Neovim’s alias for line,number and the line+screenline combination rejected as Neovim rejects it; duplicates are rejected too, matching Neovim’s own check. The grammar collapses to five reachable states, which is what the settings dropdown offers and what is stored, while vimrc and Lua accept any legal spelling and normalize into it (vim.opt.cursorlineopt = "line,number" stores both). screenline highlights only the cursor’s display row of a wrapped line, drawn as a measured RectangleMarker inside a CodeMirror layer() — a Decoration.line spans the whole wrapped block and a mark decoration would stop at the last glyph instead of filling to the content edge, so neither can express it.
    • The default becomes Neovim’s both, and migrateCursorlineoptSettings pins every existing vault to the previous number, so no installed configuration changes appearance. Stored values need no rewriting — number, line and both were already valid Neovim spellings. The one undetectable case is a vault that has never written data.json, recorded in KNOWN_LIMITATIONS.md.
    • Plugin: src/vim/cursorline-option.ts (new — grammar, normalization, canonical states), src/vim/cursorline.ts (screenline layer), src/settings.ts (type, default, both settings implementations), src/settings-migration.ts, src/main.ts, src/vimrc/loader.ts (normalize hook for string options), src/vim/options.ts, styles.css

Fixed

  • Enumerated Neovim coordinate boundaries — byte offsets, cursor/mark reads, character/display-column queries and five string-coordinate helpers use a single typed adapter. Synthetic current-window APIs and deletebufline are implemented. Interior-byte cursor writes deliberately normalize (D4); text/legacy-position/extmark repairs are described below, while strwidth and other unenumerated seams remain deferred, not parity claims.
    • Plugin: src/lua/coordinates.ts, src/lua/coordinate-wire.ts (adapter/marshalling), src/lua/api.ts, src/lua/fn.ts, src/lua/stdlib.ts (handlers), src/lua/loader.ts, src/lua/window-info.ts (resolved options and geometry), src/lua/obsidian-api.ts (explicit host-unit boundary)
  • Remaining text, legacy-position and extmark coordinate seams — text get/set and legacy positions now use byte columns; getcurpos retains sticky curswant from the fork with a positional fallback (D6). Text reads preserve exact UTF-8 slices; writes normalize interior start-down/end-up (D5). Extmarks follow that normalization, reject out-of-range columns, and serialize modeled details/virt_text correctly. These are scoped repairs with documented deviations, not whole-shim byte parity; already-real registration counts are unchanged.
    • Plugin: src/lua/coordinates.ts, src/lua/coordinate-wire.ts (conversion and raw-byte marshalling), src/lua/api.ts, src/lua/fn.ts (handler boundaries), src/lua/extmarks.ts (virtual-text chunk serialization), src/types/vim-api.d.ts (fork goal state)
  • zz centers wrapped lines on the cursor’s display row, not the line’s first row — scrollToCursor measured the cursor’s line with charCoords(Pos(line, 0)), which on a wrapped line is the box of the first display row. zz therefore centered that row and pushed everything below it down; on a line wrapping past the window height the cursor left the viewport entirely. Measured against Neovim 0.12.5 (nvim -u NONE, 80x22, wrap, scrolloff=0, smoothscroll off), zz on the final character of a long line gives topline 12 at 3 display rows, topline 18 at 15, and topline 21 with skipcol 1280 at 38 — Vim centers the whole buffer line, keeps the topline at a whole-line boundary, and scrolls within the line via skipcol only as far as needed to keep the cursor on screen. The fork now reproduces all three: it averages the first and last display rows’ boxes, clamps to the line’s first row once the line is taller than the viewport, and applies the cursor-visibility clamp to zt/z<CR>/zb/z- as well. Single-row lines are byte-identical to the previous formula, so unwrapped zz is unchanged. (#183)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (scrollToCursor)
  • Out-of-vault configurations open in the default editor — Open configuration in default editor passed the resolved path straight to App.openWithDefaultApp(), which resolves its argument through the vault adapter: getFilePath() joins onto the vault base path. A configuration found outside the vault — via globalConfigSearch in Obsidian’s userData directory, or an absolute luaConfigPath/vimrcPath — therefore became <vault>/home/…/init.lua, a path that does not exist, and the command silently did nothing. Absolute paths now route through Electron’s shell.openPath() instead, and a failure raises a notice rather than appearing to succeed. Found while implementing #182.
    • Plugin: src/util/open-path.ts (new vault-relative/external routing), src/util/external-fs.ts (openExternalPath, Electron shell access), src/main.ts (call site)
  • just check type-checked with whatever TypeScript was on PATH — the recipe called bare tsc, so with TypeScript 7 installed globally it failed instantly on tsconfig.json(12,29): error TS5108: Option 'moduleResolution=node10' has been removed. The project pins ^5.8.3, where moduleResolution: "node" is still valid, so the failure was entirely a function of the developer’s global install; every other line in the recipe goes through npm run and picks up node_modules/.bin. bump and lint had the same defect for prettier, latent only because the global copy happened to match the pinned 3.9.6 — a profile update would have silently reformatted the repo mid-release. CI never ran just, so this was a local-only false failure. This is not a TypeScript 7 compatibility fix: moduleResolution: "node" really is removed there, and choosing its replacement is a separate decision.
    • Build: justfile (check, bump, lint — no bare tool invocations remain)
  • :changes opened nothing at all — the command was registered only in setupVimSubsystems(), but reloadFeatures() (reached from cold start via main.ts) tears every registration down and then rebuilds only those registered through the central registerExCommands() path. Teardown did not remove an ex command; src/vim/registration.ts re-defined it as noopEx, so :changes stayed recognized — the fork never reported Not an editor command, and nothing was thrown — while doing nothing whatsoever. That is the worst possible failure mode for a diagnostic: indistinguishable from an empty change list. Ex teardown now calls the fork’s undefineEx(), which the fork has exported all along for exactly this, and :changes is registered in the rebuilt path alongside :jumps. An audit of the defineAction/defineMotion/defineOperator/mapCommand registrations found no other command reachable at cold start but absent from the rebuild, so :changes was the only casualty.
    • Plugin: src/vim/registration.ts (removeRegistration), src/workspace/commands.ts (registerExCommands takes the ChangeList), src/main.ts (both call sites; one-off registration removed)
  • :edit! / :e! created a junk note named ! instead of reverting the buffer — the fork’s parseInput_ ends a command name at the first non-word character, so :edit! parses as command edit with argument !. matchCommand_ resolved edit through commandMap_['e'] and createEditCommand called navigateWithJump(app, '!', ''). Observed in a live vault: the active file went from Welcome.md to !.md and the buffer emptied. The separately registered defineEx('edit!', …) handler was unreachable dead code and had been since it shipped — Vim’s standard “discard my changes and re-read the file” instead littered the vault. :edit now detects the attached bang and delegates to the existing force handler; the unreachable registration is gone.
    • Plugin: src/workspace/commands.ts (createEditCommand, registerExCommands)
  • :fold was only reachable by accident, and fo is a contested abbreviation — the fork keys its ex dispatcher by abbreviation (commandMap_[shortName]), and two commands claim fo: fold (src/fold/commands.ts) and forward (src/workspace/commands.ts). forward registers later, so commandMap_['fo'] names it, and matchCommand_('fold') walks longest-prefix-to-shortest, finds fo, sees it names forward, rejects it ('forward'.indexOf('fold') !== 0) and returns nothing. What had been keeping :fold alive was the old teardown: defineEx(name, '', noopEx) defaults its prefix to the full name, so removing fold created a commandMap_['fold'] entry that no rebuild ever cleaned up, and later registrations refreshed the handler behind it. The command was reachable only because of an entry created by its own removal — meaning on any path where that teardown had not yet run, :fold was already unreachable. VimRegistration.defineEx now creates that full-name anchor deliberately, so exact spellings resolve regardless of registration order, and undefineEx still removes every entry naming the command so teardown stays honest. fo is the only contested abbreviation across all defineEx call sites.
    • Surfaced by foldopen-golden.e2e.ts, whose k/3j cases moved by raw buffer lines because vim.cmd("4,6fold") was silently creating no fold at all.
    • Plugin: src/vim/registration.ts (defineEx, removeRegistration)
  • :violations! never cleared anything — same parse, same cause: it resolved to plain :violations and printed the list it was asked to discard. The bang is now handled inside the violations handler and the unreachable defineEx('violations!', …) registration is removed. :w!, :q!, :wq! and :xall! were audited and are unaffected — they resolve to their base handlers with ! as an ignored argument and create no files.
    • Plugin: src/workspace/commands.ts
  • vim.opt wrote string options into settings without validating them — the __newindex handler in src/lua/api.ts passed the raw value straight to onSettingOverride, consulting neither validValues nor the normalizer that the vimrc set path has always applied. Illegal values therefore landed in the settings object; legal ones only appeared correct because the subsequent setOption call happened to write a normalized value over the top. Found by driving cursorlineopt through the real vim.opt path — vim.opt.cursorlineopt = "both,screenline", which Neovim rejects, was stored verbatim — but the bypass was never specific to that option: it applied to every string option reachable from Lua. The Lua path now validates exactly as the vimrc path does.
    • Plugin: src/lua/api.ts (vim.opt string-option write path)
  • cursorlineopt had no observable effect, and the cursor line’s number stayed highlighted with cursorline off — the gutter applied vim-motions-line-num-current to the cursor line whenever a line number was drawn, consulting neither option. Neovim gates this: “CursorLineNr Like LineNr when ‘cursorline’ is set and ‘cursorlineopt’ contains number or is both, for the cursor line” (runtime/doc/syntax.txt), and drawline.c requires wp->w_p_cul && (culopt_flags & kOptCuloptFlagNumber) — CursorLineNr is never used while 'cursorline' is off. Because createCursorlineDecoration('number') correctly returns an empty extension, the default configuration (cursorline: true, cursorlineopt: 'number') meant cursorline did nothing observable at all: the highlight users saw came from the gutter unconditionally, and setting cursorlineopt=line or cursorline=false did not remove it. Both renderers are fixed — the standalone line-number gutter and the unified statuscolumn build the class independently — and the relative-number 0 on the cursor line is still emitted, since only the highlight is gated, not the number. The three divergences this originally left open — no screenline, a number default where Neovim uses both, and rejected comma lists — are closed by the grammar entry above.
    • Plugin: src/vim/cursorline.ts (setCursorlineNumberHighlight, isCursorlineNumberHighlight), src/vim/line-number-gutter.ts, src/vim/statuscolumn.ts (both number segments), src/main.ts (sets the flag at setup and rebuilds the active gutter on change)
  • Four hand-rolled copies of getEditorView() — src/ui/hint-mode.ts, src/vim/table-cell-cursor-guard.ts, src/vim/table-debug-state.ts and src/vim/vim-api.ts each re-derived view.editor.cm, three of them reproducing the same try/catch. None was wrong, but they are the sites from which the .cm.cm defect below is re-derived, so they now call the canonical accessor. src/editors/embeddable-editor.ts is untouched: its this.editor is the embeddable editor abstraction, not an Obsidian Editor.
  • Every gutter setting was a no-op until Obsidian restarted — iterateEditorViews() read editor.cm?.cm and required a dispatch method on the result. But Editor.cm is the CM6 EditorView; editor.cm.cm is the CM5 compatibility adapter the vim engine attaches, and that adapter has no dispatch. The guard therefore rejected every leaf and the callback ran for no editor at all, so reconfigureLineNumberGutter, reconfigureSignColumnGutter, reconfigureFoldColumnGutter, reconfigureStatusColumnGutter and reconfigureCursorlineHighlight all dispatched their compartment reconfigure into nothing. Toggling number, relativenumber, signcolumn, foldcolumn, statuscolumn or cursorline — from settings, vimrc or vim.opt — stored the value and updated the vim-motions-line-numbers-active body class while the gutter itself never appeared or disappeared. The body class updating on its own is what made this look like a rendering bug rather than a dead code path. This was the sole .cm?.cm in src/, against 49 uses of the canonical getEditorView() accessor that AGENTS.md already mandates for exactly this property; the call site now uses it, with an instanceof MarkdownView narrowing in place of the unchecked cast. Found while investigating #184, whose reproduction could not be set up until gutters could be toggled at runtime.
    • Plugin: src/main.ts (iterateEditorViews)

Tests

  • Source-derived documentation guard — test/unit/lua/api-status-counts.test.ts reuses api-inventory.ts to enforce source/handler/status membership, dispatch and authoritative totals, public per-name subtotals, full/no-runner inventories, duplicate-stub accounting and historical provenance. Negative controls record actual mismatches in test/fixtures/neovim-coordinate-controls.md.

  • Coordinate and audit evidence — manifest-generated conformance, independent native oracle, directional type checks, structural boundary rule and Phase 5/5b demand audit remain the regression gates. mini.surround and mini.splitjoin are both BLOCKED; Phases 6/7 were cancelled, not executed as successful integration suites.

  • Gutter vertical alignment is pinned against #184 — test/specs/gutter-line-alignment.e2e.ts measures the gutter number’s glyph centre against the line’s first text glyph, as a delta relative to a normal single-row line so theme font metrics cancel out. Obsidian sets each line’s line-height as an inline style on its .cm-gutterElement (31.07px on a heading against 24px on a body line), so number and heading glyph already share one line box: the measured heading delta is 0.00px. The requested align-items: center would centre against the taller block instead, which is not where a heading’s text sits, and would move a wrapped line’s number off its first display row. Negative controls: the issue’s own CSS puts a 7-row wrapped line’s number 72px low (row 4 of 7), and forcing the gutter element’s line-height to 24px — which needs !important to beat Obsidian’s inline style — drifts the heading to 3.5px, both against a 2px tolerance.

  • cursorlineopt is asserted through the real set and vim.opt paths — test/specs/cursorlineopt-config.e2e.ts loads an actual .obsidian.vimrc and .obsidian.init.lua rather than assigning plugin.settings, covering comma normalization, the culopt alias, reversed order and rejection on both paths. It found the vim.opt validation bypass above on its first run. Worth recording precisely: the vimrc normalize hook is load-bearing only for rejection — valid values are normalized downstream by src/vim/options.ts, so the two “normalizes” cases pass with the hook removed and only the rejection cases discriminate it.

  • Gutter alignment is measured against the display row, not the text glyph (Windows) — the assertion compared the number’s glyph centre with the heading’s glyph centre, i.e. two font content areas at different sizes. A content area is centred on its line box only when that font governs the line’s baseline; on a heading the baseline comes from the strut, so a different platform font stack shifts the heading’s centre while the gutter number does not move. Identical, correct rendering measured 0.0px on Linux and 2.5px on Windows against a 2px tolerance. The comparison is now the number’s centre against the centre of the line’s first display row, which is font-metric-free on the row side and is the invariant the plugin actually controls — where the glyph sits inside its own row is Obsidian’s typography. Deriving the row also corrected a wrong assumption: a heading’s leading space sits above its text (measured 47.06px block, 31.07px row, ~16px padding-top), so the first row starts at the end of that space rather than at the top of the border box. Both negative controls still fire and are now complementary — the CSS issue #184 proposed breaks the two wrapped cases (77.12, 72), while breaking Obsidian’s per-line gutter line-height breaks the two heading cases (3.53).

  • screenline edge cases and the combined heading case — the screenline suite adds an empty line, a closed fold (a replace decoration adjacent to the cursor) and a genuine right-to-left editor; gutter-line-alignment.e2e.ts adds a heading that is tall and wrapped, the one case where issue #184’s two failure modes coincide, which fails at 77.16px under the CSS that issue proposed. Two of these were caught being vacuous before they shipped: a Live Preview table case where the table never rendered as a widget at all (removed — the fold case already covers a replacement widget), and an RTL case asserting not.toBeNull(), which also accepts Direction.LTR and so passed without ever entering RTL. The wrapped-block guard now measures against a real single-row line instead of a 48 pixel constant.

  • cursorlineopt grammar and migration are unit-tested, screenline geometrically — test/unit/cursorline-option.test.ts covers ordering, the both alias, the rejected line+screenline pair, duplicates and whitespace; test/unit/settings-migration.test.ts covers the pin. The screenline e2e asserts the highlight is SHORTER than the wrapped block it sits in, which is the only assertion that distinguishes a display row from a logical line — a Decoration.line cannot satisfy it. Negative controls: deleting the mutual-exclusion and duplicate guards yields expected 'line' to be null and expected 'number' to be null; making the migration a no-op yields expected 'both' to be 'number', the exact surprise it exists to prevent; routing screenline to the line decoration makes the measured height null in both screenline cases.

  • CursorLineNr gating is covered in both gutters — test/specs/cursorline-number-highlight.e2e.ts runs the same four-case truth table (cursorlineopt = number/both/line, plus cursorline=false) against the standalone line-number gutter and the unified statuscolumn, asserting the number highlight and the line decoration independently. Red first: before the fix the two “does not highlight” cases failed in both gutters, 4 of 8, while the two positive cases passed.

  • All five gutter reconfigure paths are covered at the DOM — test/specs/gutter-runtime-reconfigure.e2e.ts toggles foldcolumn, cursorline, signcolumn and statuscolumn at runtime and asserts the editor DOM in both directions, including that the statuscolumn hides the individual line-number and sign gutters and restores them when cleared. One-directional assertions would not have caught a dead teardown branch. Negative control: with the iterateEditorViews fix reverted, 4 of the 5 cases fail; the fifth passes correctly because it asserts an absence (cursorlineopt=number installs no line decoration, the extension being deliberately empty for that option). Only the line-number path had DOM coverage before, which is why the defect survived 108 releases.

  • editor-view-double-cm ast-grep rule — flags $X.cm.cm and its optional-chaining forms across src/** (excluding vendored fengari). Shown to fire on the real defect at src/main.ts:5390 and to stay silent on the fixed tree, per the repo rule that a new pattern rule must be demonstrated against a defect that actually shipped.

  • should store and apply number=true via Lua now asserts that it applied — getGutterState() had computed hasLineNumbers from the editor DOM since it was written, but no test ever read it; the test checked only the stored setting and the body class, both of which are set before any editor is touched. That is what let the iterateEditorViews defect above ship and survive: with the fix reverted the gutter is absent and the five other cases in the file stay green.

  • Remaining-seams re-audit — 23 manifest APIs; core blockers removed exactly set-text-bytes, getpos-bytes, extmark-columns, plus optional get-text-bytes. Remaining non-coordinate blockers keep both behavior suites gated. The unchanged source-count guard still rejects a deliberately wrong documented figure; observed mismatch/restoration is recorded in test/fixtures/neovim-coordinate-controls.md without changing assertions.

  • mini.comment fixture pin — Phase 0 replaced the moving main ref with 27a29d6b949b9497f80a0a03421e89fed71d8c37 in test/fixtures/test-plugins.json for reproducible existing operation tests.

  • Configuration directory reveal — 12 further cases in test/unit/util/open-path.test.ts plus 4 registration cases in test/specs/config-management.e2e.ts. Two independent unit sabotages were used, because one alone could not distinguish the two ways to get this wrong: dropping the absolute-path branch failed 6 of 21 (the out-of-vault reveals only), while revealing parentDirOf(path) instead of the file failed a different 5, reporting ".obsidian" where ".obsidian/.init.lua" was expected. The vault-relative cases pass under the first sabotage and fail under the second, so neither set is vacuous.

    • The e2e control caught two of its own four cases passing with the command registration deleted: not.toBe(fileName) was satisfied by the null a missing command returns, and executeCommandById returns false for an unknown id instead of throwing, so a toBeNull() error check could not see the absence either. Both now assert presence — not.toBeNull() and executed === true — and all four fail when the registration is removed.
    • The pre-existing open-configuration execution case had the same weak shape and has been given the same treatment, since it could not distinguish a working command from a deleted one. Controlled by removing its own registration: it now reports Expected: true, Received: false, where before it passed. This is the older of the two commands, so the gap had been open since the command shipped.
  • Out-of-vault config opening — 9 cases in test/unit/util/open-path.test.ts covering vault-relative routing, absolute/tilde/Windows external routing, shell rejection, shell throw, and mobile. Verified red first by restoring the pre-fix unconditional openWithDefaultApp() call: 7 of 9 failed, with the absolute-path case reporting openWithDefaultApp called once with /home/testuser/.config/obsidian/init.lua. The 2 that passed are the vault-relative cases, which were already correct — so the suite distinguishes the two routes rather than passing wholesale.

  • Wrapped-line zz regression coverage — 3 cases in test/specs/vim-builtin/z-commands.e2e.ts (#183), measuring the cursor’s display row and the full extent of its line block rather than the logical line number. Verified red first: on a line three viewports tall the cursor sat 4437 px down a 1301 px viewport, and the gap above/below the block differed by 389.9 px against a 36 px tolerance. Each case was then sabotaged individually — halving the centering term fails the centering and unwrapped-line cases (and the pre-existing #143 zt case), disabling the visibility clamp fails only the tall-line case — so none of the three passes vacuously.

  • Exploratory suite retirement — durable coverage from test/specs/spikes/ moved into feature- and issue-named specs. Vacuous instrumentation and cases already covered by stronger normal-suite assertions were deleted; the exploratory directory is gone. 65 files and ~400 tests became 33 relocated tests; the suite went from 232 spec files to 177. wdio.conf.mts’s beforeSuite guard that skipped the vim-mode toggle for Spike:-titled suites is removed as dead code — which also means the relocated tests now run with that toggle, as every normal spec does.

  • Ex-command lifetime controls — test/unit/vim-registration-ex.test.ts runs VimRegistration against a fake that mirrors the fork’s abbreviation keying and prefix walk, and pins both halves of the invariant: a torn-down command must become genuinely unknown, and two commands sharing an abbreviation must both stay reachable by exact name across teardown/rebuild. Reverting teardown to the no-op gives expected '' to be 'UNKNOWN'; removing the full-name anchor gives expected 'UNKNOWN' to be 'fold'. Either defect flips a test rather than passing quietly.

  • solo-self-reported-success ast-grep rule — .ast-grep/rules/ gains a gate for the defect class above. vitest/expect-expect is syntactic and cannot see it: it confirms an expect() exists and nothing more. The rule fires on an it() that asserts toHaveProperty('success', true) and asserts nothing else, which is exactly the shape that cannot fail. Pairing the crash guard with a real assertion does not match.

    • Adopted only after being measured against ground truth. On the pre-sweep tree it fires 76 times and catches all 72 known-vacuous blocks with zero false positives against the 118 legitimately-paired ones; on the current tree it fires 0. The 4 it found beyond the 72 were real — blocks with two self-reported flags, which a “exactly one expect()” text survey had skipped. .ast-grep/rules/ requires a new rule be shown to fire on a defect that actually shipped before it is trusted; workspace.e2e.ts’s :w case is that defect.
    • A generalized variant matching any toHaveProperty($PROP, true) was built and rejected: it flagged 11 further blocks of which 9 were legitimate — properties computed from real DOM queries such as !!overlay and children.length > 0. Syntax cannot tell a hardcoded flag from a measured one, so the rule stays pinned to the success idiom. The 2 genuine finds among those 11 were fixed regardless.
  • 57 e2e tests that could not fail have been given real assertions — test/specs/** contained 190 it() blocks asserting expect(result).toHaveProperty('success', true), and in 72 of them that was the only assertion. success is set unconditionally by the test itself on the line after calling a void-returning function, so the assertion proved only that no exception crossed the executeObsidian boundary. Confirmed rather than theorised: replacing the ex-command strings with names that do not exist — 'w' → 'wNOSUCHCOMMAND', 'ob' → 'obNOSUCHCOMMAND' — left both tests passing. All surviving tests now assert state the feature is responsible for producing; the former spike-only remainder was either absorbed into feature specs or deleted when it proved vacuous or redundant. The 118 blocks that already paired the crash guard with a real assertion were left alone. Every rewrite was proven red by substituting a nonexistent command or key and observing the specific failure, then restored.

    • This sweep is what found the three Fixed entries above. :changes, :edit! and :violations! had passing tests the entire time they were broken, and :edit!’s test was named “should revert file without error” while the command was creating !.md.
    • The apparent fourth finding was a test bug, not a product bug. vimrc.e2e.ts wrote .obsidian.vimrc before reloadObsidian({ vault: 'test-vault' }); the reload replaced the launched vault and discarded the file. The observed default \ leader was therefore correct: vimrcCommandCount was 0, vimrcWatchPath was null, and .obsidian.vimrc did not exist in the running vault. The helper now reloads first, writes the config, and invokes the real configuration reload path. Both space and comma cases read the configured value from LeaderRegistry and assert the real EasyMotion label count; replacing the w suffix with unmapped x produced 0 labels in each case, expected >0. The documented Fixed status is accurate, and no plugin or fork change was required. (#6)
    • wdio.conf.mts’s afterTest no longer force-removes modal DOM. It closes tracked Modal instances first, because el.remove() leaves the Obsidian Scope on the keymap stack to swallow the next test’s first keystroke — the mechanism behind the gO false alarm.
    • Two apparent product defects turned out to be test defects, and both are worth carrying forward. gO “never opened the outline modal” only when it ran after :contextactions: WDIO’s afterTest force-removes modal DOM without closing the instance, leaving the Obsidian Scope on the keymap stack to swallow the next test’s first keystroke. Separately, only VimInfoModal renders .vim-motions-info-modal-title — picker/SuggestModal surfaces render no title span, so asserting on it there manufactures a false failure.
    • Three tests were deleted rather than rewritten — plain-text <C-]>, single-pane <C-w>W and single-pane <C-w>p. Each asserts a no-op, and fabricated keys produce byte-identical state, so no assertion can distinguish the feature working from the feature being absent. Per .agents/skills/negative-control/SKILL.md, a test that cannot fail is worse than none.
    • Shared helpers replace the 40-line executeObsidian block that had been copy-pasted across 11 spec files: handleEx() reports what the fork actually did with an ex command — including unknownCommand, set when the fork emits Not an editor command, which is the control that makes a fabricated command name fail — plus dispatchedCommands, getWorkspaceSnapshot(), loadTwoFileWorkspace(), getVimMarkLetters() and getInfoModalTitles().
    • Save-type commands deliberately assert dispatchedCommands rather than on-disk content: Obsidian’s idle autosave reaches the same end state within ~2 s and would mask a completely broken :w. This was verified — Welcome.md in the test vault had already been rewritten by autosave during an earlier run.
    • Tests: test/helpers.ts, test/specs/workspace.e2e.ts, test/specs/workspace-extended.e2e.ts, test/specs/ex-commands-extended.e2e.ts, test/specs/easymotion-interaction.e2e.ts, test/specs/oil-poc.e2e.ts, test/specs/oil-ex-guard.e2e.ts, test/specs/picker.e2e.ts, test/specs/vim-builtin/ex-commands-expanded.e2e.ts, test/specs/vim-builtin/link-nav-window-cycle.e2e.ts, test/specs/vim-builtin/new-commands.e2e.ts, test/specs/vim-builtin/ex-move-copy-normal.e2e.ts

Documentation

  • NEOVIM_API_STATUS.md: unchanged source-guarded counts and their semantic blind spot, closed seams and repaired serialization, exact before/after blocker lists, seven silent placeholders and remaining coordinate limitations.
  • AGENTS.md, CONTRIBUTING.md: coordinate ownership, module trees, inventory/oracle/test contracts and negative-control evidence. AGENTS.md also records the fork’s scrollToCursor display-row measurement and its Vim skipcol clamp.
  • KNOWN_LIMITATIONS.md: D4/D5/D6 and extmark normalization deviations, remaining coordinate seams, quarantined strwidth, blocked plugin suites and the resolved mini.comment moving-fixture risk. mini.splitjoin’s Vimscript-dependent string expression mapping is an architectural constraint, not a missing-function to-do.
  • README.md, docs/configuration/lua-config.md: current qualified API counts, coordinate forms/units/errors and current-window/string-helper references; no blanket plugin compatibility claim.
  • test/fixtures/neovim-coordinate-controls.md: observed documentation-guard controls and restoration evidence.
  • KNOWN_LIMITATIONS.md: the :changes and :e! entries under “Remaining weaknesses” are struck through as fixed, and both records are corrected — each had been diagnosed as a test problem and was actually a product bug. :changes was recorded as VimInfoModal.open() completing without producing DOM; in fact the closure never ran, because teardown had re-defined the command as noopEx. :e! was recorded as “reverts to the last saved disk state, not the setupEditor content”; in fact it reverted nothing and created a note named !.md. The crash-guard assertions are what made both look like test-harness faults, which is the clearest argument in the repo for why this defect class matters.
  • CONTRIBUTING.md, AGENTS.md: the new test/helpers.ts exports (handleEx, getWorkspaceSnapshot, loadTwoFileWorkspace, getVimMarkLetters, getInfoModalTitles), why ok alone is a vacuous assertion for a void-returning ex handler, and the three traps that each produced a wrong diagnosis this cycle — the VimInfoModal-only title selector, modal instances that must be closed or their Scope eats the next test’s first keystroke, and autosave masking on-disk save assertions.
  • docs/reference/keybindings.md, docs/features/ex-commands.md: checked and deliberately unchanged. Both already described :e! as “Revert current file to saved version”, :changes as “Show change list in modal” and :violations! as “Clear all recorded violations”. The documentation was correct the whole time; the implementation was not.
  • docs/configuration/settings.md, docs/configuration/vimrc.md, docs/configuration/lua-config.md: the cursorlineopt option tables carry the five canonical values, the new both default, that comma lists are accepted, and that existing vaults keep number.
  • KNOWN_LIMITATIONS.md: the paragraph listing screenline, the number default and rejected comma lists as permanent divergences is replaced by the implemented grammar, and states the one case the migration cannot detect — a vault that has never written data.json.
  • AGENTS.md, CONTRIBUTING.md: the editor-view-double-cm ast-grep rule, with the accessor to use for the CM6 view versus the CM5 adapter. Both rule lists also gain solo-self-reported-success, which has been a live gate since it shipped but was documented in neither file — a gate nobody can find is one somebody deletes. AGENTS.md’s Tier 2 spec categories now name gutter reconfiguration and cursor-line highlighting.
  • CONTRIBUTING.md: src/vim/cursorline-option.ts added to the vim/ tree, and cursorline.ts’s entry corrected — it no longer describes “number/line/both modes” now that screenline is drawn by a measured layer rather than a line decoration.
  • README.md: the line-numbers feature bullet names the full cursorlineopt grammar instead of the bare option.
  • KNOWN_LIMITATIONS.md, docs/configuration/vimrc.md: the “gutter settings require one restart” rule is removed — it was a symptom of the iterateEditorViews defect, not a design constraint, so the #101 record, the linenumbermode note and the statuscolumn note are all corrected. docs/configuration/settings.md already promised that changes “take effect immediately without restarting”; the two pages had contradicted each other, and the one that was wrong is the one that has now been fixed in code.
  • AGENTS.md: the vault-relative constraint on App.openWithDefaultApp()/App.showInFolder(), the src/util/open-path.ts routing that satisfies it, and the rule that showItemInFolder takes the file rather than parentDirOf(file). showInFolder() added to the list of typed unofficial APIs in use.
  • CONTRIBUTING.md: src/util/open-path.ts added to the util/ tree, and external-fs.ts’s entry extended with its two new OS-reaching helpers.
  • docs/reference/keybindings.md: the new command in the command table.
  • docs/configuration/lua-config.md, docs/configuration/vimrc.md: the new command alongside the existing external-editor one, that it selects the configuration file inside the revealed folder, that a shared folder is revealed once, and that both commands handle out-of-vault configurations.
  • KNOWN_LIMITATIONS.md: why both configuration commands are desktop only, separating the hard platform limit from the plugin’s own choice. App.showInFolder() has no mobile branch at all and returns void, so a mobile call is an undetectable no-op and there is no Capacitor reveal API to fall back to; App.openWithDefaultApp() does have a mobile branch via CapacitorAdapter.open(), so that command’s gate is a deliberate decision about out-of-vault configurations rather than a platform constraint.
  • CHANGELOG.md: this phase’s work is recorded only under Unreleased; released blocks are unchanged.
  • AGENTS.md, CONTRIBUTING.md, KNOWN_LIMITATIONS.md: retired the exploratory test/specs/spikes/ directory, documented that durable behavior belongs in the normal tiers, and updated active regression-test paths.

[0.149.0] - 2026-09-08

Added

  • Non-Markdown picker previews are configurable (pickerNonMarkdownPreview, set pickerpreview, vim.opt.pickerpreview) — rendered (default), hidden, or raw. Markdown is always previewed and is unaffected by the setting. Requested in #172.
    • Plugin: src/picker/sources/preview-utils.ts (policy), src/settings.ts (both settings implementations), src/vimrc/loader.ts (KNOWN_SET_OPTIONS), src/main.ts and the five file-previewing sources (live getter threading)
    • rendered embeds images/svg/canvas natively, and shows a name/type/size card for PDFs, video and audio. The card is deliberate, not a shortcut: Obsidian instantiates a PDF.js viewer per embed, and there is no released version in which repeated create/destroy is known to release that memory — the 1.9 load/unload rework was announced but the thread was closed for non-reproduction, and no changelog entry through 1.14.0 documents a PDF lifecycle or memory fix. minAppVersion is 1.7.2, below even that, so version-gating was not an option.
    • The setting is a live getter, so it takes effect without a reload or an Obsidian restart.

Fixed

  • The picker no longer reads binary files to preview them. readFilePreview called vault.cachedRead() on every TFile regardless of type or size, so scrolling past a multi-megabyte PDF decoded the whole binary as UTF-8 — and Obsidian caches that string, so memory grew with the total size of every binary encountered. The 50 KB truncation ran after the read, bounding rendering cost but not read or memory cost. Size is now checked from file metadata before any read, in every mode.
  • Preview rendering is debounced. updatePreview() coalesced only within a single requestAnimationFrame (~16 ms), but OS key repeat is ~30 ms, so every keystroke issued its own read and render — roughly 30 previews per second of held j. It now uses a 100 ms leading-plus-trailing debounce, matching OilManager.previewDebounceTimer: an isolated keypress still previews immediately, while a burst collapses to two.
  • The preview pane no longer leaks Component instances. Only renderMarkdownPreview() unloaded the previous component, so the raw-string, null, loading, and error paths all replaced pane content while leaving embeds, images and transclusions registered. A single clearPreview() now owns unload → null → empty and is called on every transition, including onClose().
  • Synchronous throws from a picker source’s preview() are caught. Promise.resolve(source.preview(...)) evaluated the provider before the promise wrapped it, so a synchronous throw escaped the .catch unhandled — despite docs/development/picker-api.md documenting that exceptions in preview() are caught. The call now runs inside the chain.
  • Stale previews are rejected by generation, not by item id. The previous guard compared currentMatches[selectedIndex].item.id, which passes if the selection moves away and returns to the same item, letting a superseded in-flight result render. Replaced with a monotonic previewGeneration, following the existing searchGeneration convention in the same file.

Tests

  • test/unit/picker/preview-policy.test.ts (13 cases) pins the read policy: no cachedRead for PDFs, images or video in any mode, size refusal above the byte budget, and Markdown exempt in all three modes. Verified red first — before the fix, cachedRead was called once on a 5,000,000-byte PDF in rendered, hidden and raw.
  • test/unit/picker/preview-lifecycle.test.ts (6 cases) covers the debounce, leading edge, component teardown, synchronous throws, and stale-result rejection. Verified red first (10 preview calls where ≤2 are expected; components left loaded; Loading… left in place by an escaped provider error), and re-confirmed by sabotage: forcing the leading-edge guard true reproduced “expected 10 to be less than or equal to 2”.

Documentation

  • configuration/settings.md listed three picker settings as unavailable via vimrc when all three have had set options. picker, pickerLeaderMappings and pickerMatcherEngine were under “Settings not available via vimrc” and showed — in their Lua and Vimrc columns, while configuration/vimrc.md documented set picker, set pickerleadermappings and set pickermatcher correctly — the two pages contradicted each other. The stale entries are removed and the columns now name the real options. Noticed while adding set pickerpreview alongside them.

[0.148.0] - 2026-09-08

Added

  • Dead-code gate — npm run lint:deadcode (knip) is blocking in CI, and npm run verify now runs four static gates. It removed 70 unreferenced declarations across 24 files plus one dead file, each confirmed independently by knip and by type-aware ESLint before deletion, and each deletion cascaded until both tools reached a fixpoint. Nothing was allowlisted that could simply be deleted.
    • Deleted: superseded table manipulation helpers (Obsidian’s native TableEditor actions supersede them), unused treesitter JS-API and query-cache surface, snippet autocomplete helpers, and src/vim/jumplist-bridge.ts, whose createJumpListBridge() had no caller — the jump list works through the JumpList class instead.
    • knip.jsonc is JSONC specifically so every ignore entry carries its reason inline. The categories are: vendored fengari, dependencies selected by string name in wdio.conf.mts (framework: 'mocha', reporters: ['obsidian'], runner: 'local'), and the ambient __DEV__ declaration. The bridge ignores were removed when it was wired up.
    • Fixed three test files importing ../../../../src/lib/fengari, one level above the repository root. Vite resolved it leniently so the tests passed; knip did not.
  • Test-quality gate — test/ was in ESLint’s globalIgnores, so 346 files and 4,259 test blocks had never been linted at all. @vitest/eslint-plugin now covers test/unit/** and eslint-plugin-wdio covers test/specs/**, both blocking. It surfaced 166 findings, all fixed rather than suppressed — no eslint-disable was added.
    • 46 wdio/no-floating-promise: an async browser assertion that is built but never awaited resolves to a pending Promise, is truthy, and never throws, so the test passes regardless of what the browser did. wdio/await-expect ships off in the plugin’s own recommended set and is forced on here.
    • 57 vitest/expect-expect, 13 no-conditional-expect, 10 no-identical-title, 7 valid-expect, 2 no-standalone-expect, plus one tautological self-comparison and one constant-folded null ?? {} that made a test a silent duplicate of the next one.
    • wdio/no-pause is off (2902 occurrences of an established browser.pause() idiom — a flakiness question, not a vacuity one), and the general lint backlog in test/ is switched off there and separately owned. Neither is suppressed for src.
    • expect-expect is syntactic and trusts any helper named in assertFunctionNames without inspecting it, so it proves a test asserts something, never that it asserts the right thing. luaExpectOk and run were added only after reading their definitions and a call site; run is scoped to test/unit/lua/** and test/unit/fengari/** because the name is generic enough that trusting it suite-wide would let any future function so named satisfy the rule.
  • negative-control skill (.agents/skills/negative-control/SKILL.md) — generalises the “the test MUST fail first” requirement from issue-repro, which only applied to GitHub-issue bug fixes and mandated e2e tests. It now covers every new or modified test: unit tests written alongside a feature, tests added for existing code, and tests touched during a refactor. Ranks three techniques (red-first, sabotage the subject, invert the assertion), requires concrete observed failure values rather than “I verified it fails”, and carries a nine-item vacuity checklist. issue-repro keeps its own workflow and the two cross-reference each other.
  • require() resolves synchronously — every .lua file under lua/ is read into memory when the configuration loads, and require() resolves from that snapshot. This unblocks lazy require, which is the idiom nearly the whole modern Neovim plugin ecosystem is built on: vim.keymap.set('n', 's', function() require('plugin.jump').start() end) previously failed with module 'plugin.jump' not found: async APIs can only be called from async-capable callbacks, because reading from the vault is asynchronous and keymap callbacks run on the main state via a plain lua_pcall that cannot yield. Only a cache miss was ever affected — package.loaded hits were already synchronous.
    • Plugin: src/lua/module-snapshot.ts (new — walk, index, limits, atomic swap), src/lua/package.ts (snapshot-first resolution), src/lua/coroutine-runner.ts (isAsyncCapable), src/lua/loader.ts (awaited rebuild before user config, refresh after plugin fetch, skip reporting)
    • The asynchronous vault read is retained, but only for callers that can wait for it — top-level configuration, autocommands, timers. A synchronous caller that misses the snapshot is told the module is not present in the configuration snapshot, naming both paths tried, rather than a generic “not found” indistinguishable from a typo.
    • Files added or edited after the configuration loads need a reload; there is no live watcher. The snapshot rebuilds on configuration reload and after a vim.plugins.add() fetch, before the fetching coroutine resumes, so a freshly fetched plugin is immediately requirable.
    • Limits are reported, not silently applied: 512 KiB per file, 16 MiB total, 2,048 files, 32 directory levels. Skipped files are named in the console with a reason, because a file dropped for exceeding a budget would otherwise present as a missing module.
    • The snapshot reader and the async-capability predicate reach the injected require chunk as chunk arguments, not globals, so sandboxed user Lua has no handle on them.
    • Measured against the flash.nvim diagnostic: require_in_callback went from blocked: … async APIs … to works, and Config.get().search.multi_window from an error to true. flash now runs to its LuaJIT FFI dependency, which is architectural and not fixable in a pure-Lua VM.
  • vim.ui.select and vim.ui.input — Neovim’s UI-hook namespace, backed by the existing Telescope-style picker and input modal. vim.ui is a plain mutable table with no metatable, so dressing.nvim / telescope-ui-select / snacks can replace and restore its fields, which is the idiom the namespace exists for. Both are non-blocking: they return immediately and invoke their callback later on a coroutine thread, so they work from inside a vim.keymap.set callback — the most common call site, where a yield-based design would hard-error. on_choice receives the original Lua value (items are commonly tables) plus a 1-based index; on_confirm distinguishes '' (empty confirm) from nil (cancel). format_item is applied eagerly, matching Neovim’s own default implementation.
    • Plugin: src/lua/ui-api.ts (new), src/lua/loader.ts (injection + wiring), src/main.ts (openUiSelect picker binding)
    • Where no selection UI is available, vim.ui.select raises rather than settling with nil — a caller cannot distinguish a nil settle from “the user cancelled”.
    • vim.ui.open(path, opts?) opens http(s):// targets in a new window and everything else with the system handler, returning Neovim’s vim.SystemObj|nil, nil|string shape. On mobile, or with no handler, nil, errmsg is the correct answer rather than a fudge. opts.cmd is rejected outright — arbitrary command execution is against the plugin’s security posture.
    • vim.ui.progress_status() returns '', which is exactly what Neovim returns when no progress is active.
    • An open picker is closed before the Lua state is destroyed, so a late selection cannot invoke into a closed lua_State.
    • Design validated before implementation by test/specs/spikes/spike-ui-callback-context.e2e.ts, which proves a keymap callback cannot yield but a callback it schedules can.
  • Picker reports cancellation — PickerOptions.onCancel fires exactly once when the picker closes without a selection, and PickerModal.closeActive() closes a live picker. Needed by any caller that must distinguish “chose nothing” from “chose something”, such as a Neovim-style vim.ui.select. confirmSelection closes the modal before dispatching the selection, so onClose runs first; a didConfirm flag set synchronously before close() keeps a successful selection from also reporting a cancel.
    • Plugin: src/picker/picker.ts (didConfirm, onClose cancel dispatch, closeActive), src/picker/types.ts (onCancel)
  • nvim_set_decoration_provider(ns, opts) — real implementation of Neovim’s per-redraw decoration callbacks (on_start → on_buf → on_win → on_end), backed by a CodeMirror ViewPlugin that coalesces work into one requestAnimationFrame per frame rather than dispatching from inside update() (which CodeMirror rejects). Guarded by a transaction annotation, a re-entrancy flag, a per-view rAF handle, a runtime generation counter, a 100k instruction limit per callback, and a fault counter that disables a provider after 8 consecutive errors. on_win returning false skips the rest of that provider’s cycle, matching Neovim.
    • Plugin: src/lua/decoration-provider.ts (new — manager + CM6 extension), src/lua/api.ts (handler), src/lua/loader.ts (wiring + registerStateCleanup), src/main.ts (extension registration)
    • on_line and on_range raise a Lua error naming the unsupported key rather than being silently accepted; ephemeral extmarks likewise. Erroring is closer to Neovim than silently persisting, and avoids the accumulating-stale-decoration failure.
    • Mechanism validated before implementation by test/specs/spikes/spike-decoration-provider-raf.e2e.ts (Phase 0b): 7 assertions including two negative controls that reproduced the re-entrancy error and a synthetic feedback loop.
  • Extmark hl_eol, strict, and priority ordering — nvim_buf_set_extmark now parses hl_eol (extends the highlight to the end of the line containing the range end) and strict (out-of-range positions clamp instead of dropping the mark). Overlapping marks are ordered by priority, deterministically and independently of insertion order.
    • Plugin: src/lua/api.ts (opts parsing), src/lua/extmarks.ts (hlEol/strict in ExtmarkOpts, line-aware buildDecorations, priority-aware sort)
    • Known gap: priority orders decorations but does not yet decide which wins visually — CM6 marks carry no z-index and Decoration.set(..., true) re-sorts. Recorded in KNOWN_LIMITATIONS.md.
  • LuaJIT bit library — Neovim runs LuaJIT, which Neovim documents as its permanent plugin interface, so plugins reach for bit.band/bor/lshift rather than Lua 5.3’s native &/| operators. The library was absent entirely; it is now available with LuaJIT’s semantics, including signed 32-bit results (bit.bnot(0) is -1, not 4294967295) and the distinction between logical rshift and arithmetic arshift. Implemented arithmetically rather than with native operators: Lua 5.3’s & requires an exact integer representation, and this VM widens integers to 53 bits, so a value arriving as a float raised “number has no integer representation”.
    • Plugin: src/lua/engine.ts (luaCompatShims)
  • require("ffi") fails with an accurate message — LuaJIT-only natives previously fell through to the module file read and surfaced whatever that failed with, which described the wrong problem. They now report that the module requires LuaJIT and that this runtime is a pure-Lua VM. Ordinary missing modules are unaffected.
    • Plugin: src/lua/package.ts
  • nvim__redraw is a warn-once stub again, deliberately truthy — it briefly read as nil so that flash’s if vim.api.nvim__redraw then probe would take its fallback. That was right for highlight.cursor, whose fallback is nvim_buf_set_extmark, but wrong for hacks.setcursor, whose fallback is LuaJIT FFI and which is called unguarded on every keystroke through Util.get_char. One name, two opposite correct answers; the truthy stub avoids throwing on the hot path, at the cost of highlight.cursor no longer drawing its cursor highlight. The nil-reading dispatch tier introduced for it has been removed rather than left with no members.
    • Plugin: src/lua/api.ts
  • Vim regex translation — vim.fn.searchpos, vim.fn.split and vim.regex compiled their pattern with new RegExp, so Vim syntax silently matched nothing: \V and \C are identity escapes in JavaScript, making \Valpha\C a search for the literal ValC. A shared translator now handles magic levels (\v, \m, \M, \V), the case flags \c/\C, \zs/\ze as lookbehind/lookahead, \</\> word boundaries, \%( non-capturing groups, and the Vim character classes.
    • Plugin: src/lua/vim-regex.ts (new), src/lua/vim-search.ts, src/lua/fn.ts (split), src/lua/regex.ts
    • Breaking: these three now take Vim patterns rather than JavaScript ones, which is what Neovim documents them to take. At the default magic level +, ?, (, ) and | are literal, so a JavaScript pattern such as \d+ must be written \d\+. NEOVIM_API_STATUS.md previously recorded the ECMAScript behaviour as a known deviation.
    • Found by auditing flash.nvim’s API usage rather than by hitting it: vim.fn.split(s, "\zs") is Vim’s split-into-characters idiom and flash uses it to build label lists, so default label generation was broken independently of the search.
  • Indexed scope access: vim.bo[buf], vim.b[buf], vim.wo[win], vim.w[win], vim.t[tab] — Neovim allows both vim.bo.filetype and vim.bo[bufnr].filetype, and plugin code uses the indexed form freely. Our proxies accepted string keys only, so an indexed access resolved to nil and the caller failed with attempt to index a nil value. A numeric or nil key is now validated as handle 0 and returns the scope table. This closed the last of three blockers preventing flash.nvim from rendering: flash/cache.lua reads vim.bo[buf].filetype and vim.b[buf].changedtick, and with both fixed flash.state.new{...} completes and writes extmarks into the document.
    • Plugin: src/lua/api.ts (isScopeHandleKey, indexed branch on all five scope proxies)
  • nvim_list_bufs() and nvim_tabpage_list_wins() — both previously warn-once stubs returning an empty list, which is never a valid answer: there is always at least the current buffer and window. Each now returns {0}, consistent with nvim_list_wins() and the current-handle APIs. nvim_tabpage_list_wins validates its argument through a new requireTabpageZero guard. Measured impact: flash.nvim’s Cache:_update_wins() overwrites state.wins with the filtered result of nvim_tabpage_list_wins, so an empty list left it with zero windows, zero matches, and nothing rendered.
    • Plugin: src/lua/api.ts (requireTabpageZero, both implementations, promoted into SUPPORTED_NVIM_API_FUNCTIONS)
  • Unicode index conversion for vim.fn — strchars(s, skipcc?), charidx(s, byteidx, countcc?), and byteidx(s, nr) convert between UTF-8 byte offsets and Vim character indices. strchars counts composing marks separately unless skipcc is set; charidx and byteidx fold them into the preceding base character, matching Vim. All three are on flash.nvim’s default label-positioning path.
    • Plugin: src/lua/fn.ts (buildCharSpans byte-span mapping, three registrations)
  • vim.fn.wincol() and vim.fn.winlayout() — wincol() reports the cursor’s screen column measured from the window edge, so the gutter counts, derived from CodeMirror geometry with a cursor-column fallback when geometry is unmeasurable. winlayout() reports a single leaf whose window handle matches nvim_list_wins(). wincol is on leap.nvim’s search path; winlayout is on flash.nvim’s window-layout save path.
    • Plugin: src/lua/window-info.ts (getCursorWinCol), src/lua/fn.ts (registrations)
  • Window-local option scope (vim.wo) — replaces the warn-and-return-nil placeholder with a real proxy. wrap reports CodeMirror’s line-wrapping state; writes shadow the resolved value; every other key falls back to the global scope, matching Neovim where an unset :setlocal value resolves to the global one. Required by leap.nvim’s core search loop.
    • Plugin: src/lua/api.ts (readWindowOption, window-option shadow, vim.wo proxy), src/lua/loader.ts (getWindowOption callback)
  • vim.bo.iminsert and vim.bo.fileformat — buffer-local options read by flash.nvim and leap.nvim on every invocation, and by nvim-surround.
    • Plugin: src/lua/loader.ts (getBufferOption cases)

Changed

  • Global option fallbacks — vim.o/vim.go resolve engine → shared shadow store → defaults (eventignore, selection, cmdheight, columns, lines, cpo, theme-derived background) → nil. Unsupported writes can be retained as compatibility values without implementing their Neovim behavior.
    • Plugin: src/lua/api.ts (global option reads/writes), src/lua/loader.ts (normalize returned Error objects for unknown fork options)
  • Plugin query retention — downloads retain .scm query files under isolated lua/{owner}__{repo}/queries/ roots and refresh the snapshot before Lua resumes. Older cached plugins must be re-fetched to acquire previously discarded queries; .scm edits require a configuration reload. Resolution is bounded to 128 KiB/file, 4 MiB/snapshot, 512 KiB/combined query, 64 sources, and 16 inheritance levels, with diagnostics and affected-content skipping.
    • Plugin: src/lua/plugin-fetch.ts, src/lua/plugin-store.ts (archive retention, isolated storage and refresh), src/treesitter/query-files.ts, src/treesitter/named-queries.ts (limits)
  • nvim_create_namespace returns unique IDs — previously hardcoded to 0, now returns unique integer IDs per namespace name, matching Neovim behavior. Required for the extmark system and highlight namespace isolation.
  • ~30 previously stubbed vim.* utilities now have real implementations — functions that were no-op stubs or returned placeholder values now work correctly (e.g., vim.is_callable, vim.stricmp, vim.pesc, etc.)
  • Settings that decide which editor extensions are installed now apply without a restart — reloadFeatures() never touched vimExtensionSlot, which only setupVimSubsystems() populates, and that runs from onload() and enableVim() only. animatedCursor, enableSnippets, snippetTriggerMode and enableUndoTree were therefore restart-only, and enableUndoTree never reached a reload path at all. Re-running setupVimSubsystems() is not an option — it is a one-shot builder that registers global handlers and constructs managers, Lua and autocmd state — and teardownVimSubsystems() is far too destructive for a settings change. Each gated feature now owns a nested Extension[] that is pushed into vimExtensionSlot once and whose contents are swapped in place, followed by a single workspace.updateOptions(). Built extensions are cached so their identity is stable, which is what allows CodeMirror to keep existing ViewPlugin instances alive across an unrelated reload. The snippet runtime sits in its own slot, separate from the completion and tab integrations, so changing snippetTriggerMode leaves an in-progress snippet session intact. (#181)
    • Plugin: src/main.ts (setSlotEnabled, populateRuntimeSlots, refreshRuntimeExtensionSlots, five feature slots, extension builders extracted from setupVimSubsystems), src/settings.ts (enableUndoTree added to RELOAD_KEYS and to the imperative handler)
  • Cursor shape changes now reach the animated cursor without a restart — cursorShapes has two independent consumers, and only one was broken. The fork reads state.vim.cursorShapes live on every render and already tracked settings changes at runtime. The animated cursor keeps its own copy, made by setCursorShapes(), which only setupVimSubsystems() called — so with the animated cursor enabled a shape change did nothing until Obsidian restarted. A slot cannot fix this: the bundled vim extension is never gated, so the reload path re-pushes the value instead. (#181)
    • Plugin: src/main.ts (reloadFeatures() re-applies setCursorShapes)
  • The animated cursor could stay missing after scrolling back to the caret — caught by CI on Windows, where it never returned; on Linux it came back after ~650 ms, which the original “not null” assertion accepted. Three faults stacked. wake() was not sticky: one arriving while a frame was in flight returned early on running, and that frame then parked the loop, discarding it. The blink’s dark half is 600 ms and the warm gear also ticks every 600 ms, so a parked loop can land on the dark half of every blink and draw nothing indefinitely — a scroll now counts as movement for blink purposes and shows the cursor solid, as Neovim does. And the scroll listener deduplicated against cachedScrollTop, which stops advancing while the caret is off-pane, so scrolling back to exactly the last resolved offset looked like no change and skipped the wake. Measured 645 ms → 55 ms. (#181)
    • Plugin: src/vim/animated-cursor/manager.ts (wakeRequested consumed by scheduleNext), src/vim/animated-cursor/controller.ts (lastSeenScrollTop/Left, positionRetryUntil, blink reset on scroll)
    • The spec now bounds how long the cursor may take to come back rather than only that it does. “It came back eventually” is what hid all three behind the 600 ms warm frame that rescued Linux and not Windows. Each fault was reverted separately and observed failing the bound at 659 ms and 647 ms.
  • Teardown no longer resurrects the animated-cursor manager — teardownVimSubsystems() destroys the manager, but CodeMirror destroys the controllers only on the later updateOptions(), so CursorController.destroy() called getAnimatedCursorManager() after teardown and built a replacement purely to deregister from it. The replacement had no canvas, rAF loop or listeners, so this leaked an object rather than causing a visible fault. destroy() now uses peekAnimatedCursorManager(), which never creates — correct regardless of which order the two steps happen in, unlike reordering teardown would be. (#181)
    • Plugin: src/vim/animated-cursor/manager.ts (peekAnimatedCursorManager), src/vim/animated-cursor/controller.ts (destroy())

Fixed

  • di(/di{/di[ did nothing when the cursor sat before the pair — da( searched forward for the next pair when the cursor was not already inside one; di( did not, because the fallback in textObjectManipulation was gated on inclusive. Neovim applies the same forward search to both variants (:h v_i( — “when the cursor is not inside a () block, find the next ’(’”), and the search is not limited to the current line. di" was unaffected because quote objects use a separate scanner that already moved the cursor to the first quote. (#178)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (textObjectManipulation — forward pair search no longer gated on inclusive)
  • Surround ysi$/ysa$ and every other registered text object were dropped — ys stores its text object in a ys_motion sub-state and, on the next key, called the fork’s built-in textObjectManipulation directly. That function only knows the built-in objects (( ) { } [ ] < > ' " ` b B w W p t s), so plugin-registered objects (i$, a$, i=, i~, i_, il, iC, io, i, …) resolved to nothing, the operation was cancelled, and the pending ysi vanished from the chord display. Resolution now matches normal operator-pending: the exact key sequence is looked up in the keymap first, falling back to the built-in object so a registered object that shadows a built-in one (aB, blockquote vs {} block) only wins where it actually matches. The same helper is used by ys dot-repeat. (#179)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (runTextObjectMotion, used by handleSurroundSubState’s ys_motion branch and by repeatCommand)
  • vim.keymap.set("", lhs, rhs) only mapped normal mode — Neovim’s :h map-modes defines the empty mode string as Normal + Visual + Select + Operator-pending, but getModeList() collapsed it to ['n'], the same value it uses for a missing mode argument. A config shifting the home row (vim.keymap.set('', 'j', 'h')) worked in normal mode and silently reverted to the default motion the moment the user pressed v or an operator. The empty string now expands to n, v, s, o, and an empty string appearing as a table entry ({""}) expands the same way instead of contributing nothing. Insert mode is deliberately excluded, matching Neovim. (#180)
    • Plugin: src/lua/api.ts (getModeList, EMPTY_MODE_EXPANSION) — affects both vim.keymap.set and vim.keymap.del
  • Animated cursor did not follow the text while scrolling, and left an uncleared phantom behind — CursorController.update() already compared scrollDOM.scrollTop/scrollLeft against its cached values, but it only runs when CodeMirror produces a ViewUpdate, and scrolling inside the already-rendered viewport produces no transaction at all. The only recovery was the 500 ms staleness check in tick(), and in the warm gear the next tick is up to 600 ms away, so the cursor never caught up during a continuous scroll. Separately, once the caret scrolled outside the pane coordsToRect() returned null and refreshTarget() returned early without clearing cachedRect, so tick() kept repainting the last known rect together with its cached character — the reported “phantom letter”. That phantom could not clear itself: a controller that draws always leaves a non-null dirty region, so the manager never reached its full-canvas clear. A passive scroll listener on scrollDOM now marks the position dirty and wakes the manager, and a caret that leaves the pane drops its cached rect. The pane test became a vertical intersection rather than containment, so a caret line half-clipped by the pane edge still renders — draw() already clips to the same rectangle — instead of blinking out at the top and bottom of every scroll. (#181)
    • Plugin: src/vim/animated-cursor/controller.ts (onScroll listener registered in the constructor and removed in destroy(), refreshTarget() clears cachedRect/cachedShapeRect, coordsToRect() vertical intersection test)
  • The character under a block cursor stayed behind when scrolling — the other half of the “phantom letter” in #181, and a different mechanism from the stale rect above. resolveBlockChar() caches charTop/charHeight, the viewport coordinates of the character’s DOM rect measured for #106 so the glyph is centred on tall lines, keyed only on the document position. Scrolling does not change the position, so the cache hit handed back coordinates measured before the scroll: fillRect drew the block at the new screen position while fillText painted the letter at the old one. Because the frame reports only the block’s rect as dirty, the stranded letter fell outside every subsequent clearRect and stayed on the page. Canvas instrumentation after a 40 px scroll recorded the block at y 668.9 and the glyph still at y 711.9–723.9. The cache key now includes the cursor rect’s screen top. (#181)
    • Plugin: src/vim/animated-cursor/controller.ts (resolveBlockChar(pos, rectTop), cachedBlockCharTop)
  • Table navigation shared its pending-key state across split panes — pendingD and the count buffer lived at module scope while TableNavController is a CodeMirror ViewPlugin, instantiated once per EditorView. Pressing d in one pane and then a key in another consumed the first pane’s pending state, so d followed by d in a different table deleted a row immediately instead of starting its own dd. A count typed in one pane applied to the next motion in another. Both now live per handler. Dot-repeat is deliberately left at module scope: Vim’s . replays the last change across buffers.
    • Plugin: src/vim/table-nav-keymap.ts (per-handler state, resetPending), src/vim/table-nav-controller.ts (holds its own handler)
  • Picker source timeouts leaked a live timer per call — Promise.race settles but does not cancel the loser, so every items(), search() and preview() call against an external source left a 5-second timer holding its closure. search() runs on every keystroke. The timer is now cleared whichever side wins.
    • Plugin: src/picker/api.ts (withTimeout)
  • Visual-line pending selection lost its expiry in split panes — the selection was stored per EditorView in a WeakMap but guarded by a single module-scope TTL timer, so the second view to store a selection cleared the first view’s timer and that entry never expired. The timer is now stored beside the selection it expires.
    • Plugin: src/vim/visual-line-command-fix.ts
  • Treesitter-backed Markdown text objects — validate exact opening and closing delimiter runs and continue to enclosing nodes when a candidate does not match. This fixes nested strikethrough ranges, single/double dollar confusion, and asterisk/underscore aliasing. Inner objects reject cursor positions on their delimiters. Blockquotes exclude lazy continuation lines below the cursor’s quote depth and reuse depth-aware prefix and newline handling.
    • Plugin: src/text-objects/delimiter.ts, src/text-objects/blockquote.ts, src/treesitter/js-api.ts, src/treesitter/runtime.ts
  • Treesitter-backed fold metadata — heading fold ranges and heading/code placeholder labels now read immutable plain data keyed by exact editor state, replacing unreachable tree-field lookups. Selection-only states retain metadata; heading-like lines inside fences do not trigger the regex heading fallback when metadata is present. Column-zero exclusive section ends exclude the following same-level heading, with trailing blank lines trimmed and nested sections retained. Frontmatter/callout precedence and placeholder formats are unchanged.
    • Plugin: src/fold/metadata.ts (new extraction and state cache), src/treesitter/bridge.ts (publication), src/fold/provider.ts, src/fold/placeholder.ts (metadata consumers), src/treesitter/tree-state.ts (removed obsolete tree field/effect)
    • The CM6 bridge is now actually installed. createBridgeExtension had no caller anywhere, so the per-view incremental-parsing ViewPlugin had never run — treesitter worked only because js-api.ts and the Lua vim.treesitter API parse on demand. main.ts installs it through enableTreesitterBridge() after the Markdown grammars load, via a mutable extension slot and workspace.updateOptions(). JS syntax-aware consumers keep their existing fallbacks for the window before it is available, and the Lua API keeps its own parser cache.
      • Plugin: src/main.ts (enableTreesitterBridge), src/treesitter/bridge.ts (createBridgeExtension)
  • vim.bo writes were silently discarded — setBufferOption was an empty function, so every vim.bo.x = y assignment did nothing and the next read returned the computed default. Writes now round-trip through a per-file shadow store, and expandtab, shiftwidth, softtabstop, tabstop, and textwidth are forwarded to the vim engine. Plugins that save and restore a buffer-local option around an operation now observe their own value.
    • Plugin: src/lua/api.ts (readBufferOption/writeBufferOption shadow store), src/lua/loader.ts (ENGINE_BACKED_BUFFER_OPTIONS forwarding)
  • Guarded nvim__redraw probes crashed instead of degrading — the vim.api dispatch metatable raises on property read for unregistered names, so flash.nvim’s if vim.api.nvim__redraw then and leap.nvim’s pcall(vim.api.nvim__redraw, ...) both errored at the guard itself rather than falling back. Added a third dispatch tier, ABSENT_NVIM_API_FUNCTIONS, whose members read as nil: not raising (which crashes the probe) and not a warn-once stub (which is truthy, so flash would take the branch meant for hosts that have the API and silently lose the cursor highlight its else branch draws via nvim_buf_set_extmark). nil is also what leap’s pcall expects on a Neovim build without the API.
    • Plugin: src/lua/api.ts (ABSENT_NVIM_API_FUNCTIONS, dispatch metatable)
  • Command-line, history, and mapping probes raised instead of degrading — getcmdline, setcmdline, getcmdpos, getcmdwintype, wildmenumode, complete_info, histadd, histdel, and mapset were unregistered, so calls raised a Lua error. Registered as warn-once stubs. For the read-only probes the placeholder is exactly what Neovim returns when no command line, wildmenu, or completion popup is active.
    • Plugin: src/lua/fn.ts (stub sets, new voidReturnFns set for mapset)
  • Lua iterator pipelines — real vim.iter for list-like tables, map-like tables, iterator functions, and callable tables, with 26 methods. rpop, count, and size are extensions beyond Neovim 0.12; size() requires a list source and raises on function sources.
    • Plugin: src/lua/iter.ts (embedded Lua implementation), src/lua/loader.ts (inject after namespace stubs)
  • Physical key observation — vim.on_key(fn, ns?) registers, replaces, and removes namespace-scoped callbacks and returns the namespace ID. Observation is pre-mapping, not Neovim’s post-mapping hook: both arguments contain the same physical input, mapped expansions/programmatic feedkeys are not separately observed, and return values cannot discard keys.
    • Plugin: src/lua/on-key.ts (registry, guarded dispatch, teardown), src/workspace/key-observer.ts (physical-key observation)
    • Plugin: src/workspace/global-key-handler.ts (desktop/popout dispatch), src/main.ts (mobile dispatch through the existing safety handler)
    • Plugin: src/lua/api.ts, src/lua/loader.ts (registration/wiring), src/lua/engine.ts (cleanup before Lua close), src/lua/stdlib.ts (remove old no-op shim)
  • Current-editor compatibility APIs — vim.fn.getwininfo([winid]) returns CM6 visible lines (1-based inclusive), viewport dimensions, and gutter offset; non-zero handles or no editor return an empty list. nvim_win_call(0, fn)/nvim_buf_call(0, fn) invoke directly with return/error propagation, and nvim_win_get_config(0) reports a non-floating window (relative = ''). Authoritative registration totals are 60 real vim.api implementations and 79 real vim.fn implementations, excluding stubs and correcting earlier documentation counts.
    • Plugin: src/lua/window-info.ts, src/lua/fn.ts (viewport geometry and registration), src/lua/loader.ts (adapter callback), src/lua/api.ts (current-handle APIs)
  • Named Treesitter query files — resolve query.set() overrides, user lua/queries/{lang}/{name}.scm, lexically ordered plugin lua/{plugin}/queries/{lang}/{name}.scm, then bundled Markdown/Markdown inline/HTML textobjects. Supports ;; extends, recursive ;; inherits: with optional (language) syntax, cycle detection, lazy compilation, and cache invalidation. query.get_files() reports vault-relative physical paths only, omitting bundled constants.
    • Plugin: src/treesitter/bundled-queries.ts, src/treesitter/query-files.ts, src/treesitter/named-queries.ts (bundled queries, file snapshot, resolution and limits)
    • Plugin: src/lua/treesitter/api.ts, src/lua/treesitter/query-api.ts, src/lua/loader.ts (query APIs and awaited runtime/query preloading before user config)
  • Massive Lua API expansion (~260 Neovim API functions) — 65 new functions across vim.fn, vim.api, vim.validate, vim.version, and the extmark system. Enables Lua ports of mini.surround, mini.ai, leap.nvim, flash.nvim, and nvim-surround.
    • Plugin: src/lua/fn.ts (12 new vim.fn.* functions)
    • Plugin: src/lua/api.ts (16 new nvim_* API functions)
    • Plugin: src/lua/stdlib.ts (upgraded vim.validate, vim.keycode, vim.notify_once, vim.version namespace with 11 functions, ~30 previously stubbed utilities now real)
    • Plugin: src/lua/loader.ts (callback wiring for all new functions)
    • Plugin: src/lua/namespace-stubs.ts (removed 'version' — now real implementation)
    • Plugin: src/lua/extmarks.ts (new — Neovim extmark system: StateField, registry, effects, VirtualTextWidget, position tracking, query APIs)
    • Plugin: src/ui/input-modal.ts (new — Obsidian Modal for vim.fn.input() prompt)
    • Plugin: src/main.ts (registered extmark extension)
    • Styles: styles.css (.vim-motions-input-modal-input styles)
  • 12 new vim.fn.* functions: visualmode, winsaveview/winrestview, foldclosed/foldclosedend, shiftwidth, strdisplaywidth, strcharpart, maparg, getcharstr/getchar (async key input waiting), searchpos (regex buffer search), input (async user prompt via modal)
  • 16 new nvim_* API functions: nvim_get_mode, nvim_strwidth, nvim_buf_is_valid, nvim_buf_get_text, nvim_del_current_line, nvim_list_wins, nvim_buf_get_keymap, nvim_get_vvar/nvim_set_vvar, nvim_get_option_value/nvim_set_option_value, nvim_buf_set_extmark, nvim_buf_get_extmarks, nvim_buf_get_extmark_by_id, nvim_buf_del_extmark, nvim_buf_clear_namespace
  • vim.version namespace — 11 functions: vim.version() returns plugin version as {major, minor, patch}, vim.version.parse(), vim.version.cmp(), vim.version.lt()/gt()/eq(), vim.version.range() with has(), vim.version.last(), plus __tostring/__eq/__lt metamethods on version objects
  • vim.validate() full Neovim spec — both old table form (vim.validate({ name = { value, "string" } })) and new positional form (vim.validate("name", value, "string")) with optional flag support (Neovim 0.11+ compatible)
  • vim.keycode() key code translation — translates Neovim key notation to readable strings (e.g., vim.keycode("<CR>") → "\r", vim.keycode("<Esc>") → escape character)
  • vim.notify_once() deduplication — shows each unique message only once per session
  • Extmark system — nvim_buf_set_extmark (virtual text with virt_text, virt_text_pos, hl_group, sign_text, priority), nvim_buf_get_extmarks (range queries and full-buffer listing), nvim_buf_get_extmark_by_id (single extmark lookup), nvim_buf_del_extmark (removal), nvim_buf_clear_namespace (bulk clearing). Enables flash.nvim, leap.nvim, and nvim-surround Lua ports.
  • vim.fn.getcharstr() — async key input waiting that yields the Lua coroutine until a key is pressed. Enables interactive Lua plugins (mini.surround, mini.ai, leap.nvim)
  • vim.fn.searchpos() — regex buffer search returning {line, col} position. Enables flash.nvim, nvim-surround, and leap.nvim Lua ports
  • vim.fn.input() — async user input prompt via Obsidian modal. Enables nvim-surround and other interactive Lua plugins
  • operatorfunc option routes — direct Lua functions, function-name strings, and nil clearing now share handling through vim.opt, vim.o, vim.go, nvim_get/set_option, and nvim_get/set_option_value. The previously advertised vim.o.operatorfunc path now works with the fork’s g@ operator.
    • Plugin: src/lua/api.ts (shared operator callback read/write helpers)
  • Termcode identity-function bug — nvim_replace_termcodes emits real Neovim key bytes (<CR> becomes "\r"), and nvim_feedkeys decodes them back to notation at the fork boundary. Binary termcodes also work in mappings and expression callback results.
    • Plugin: src/lua/termcodes.ts (byte encoder/decoder), src/lua/api.ts (key API integration)
  • Query iterator source and row arguments — iter_captures/iter_matches now use supplied or node-retained document text and read row bounds after the source argument, fixing predicates in file-loaded queries.
    • Plugin: src/lua/treesitter/query-api.ts (source selection and argument positions)
  • Jump list stalled on unresolvable entries — <C-o>/<C-i> now skip history entries whose file no longer resolves and land on the nearest valid entry in the direction of travel. Previously the history index advanced before path resolution was checked, so a dead entry aborted navigation and left the cursor on an unrelated file; an exhausted run of dead entries could also fall through to the fork’s separate within-buffer history. Dead entries no longer consume the count, the scan is bounded by history length, and the index is left unchanged when no valid destination exists. Navigation skips rather than prunes; vault.on('delete') continues to prune eagerly. Affects any persisted history, regardless of whether the entry was recorded by gd, the picker, harpoon, oil, or hint mode.
    • Plugin: src/vim/jumplist.ts (validity predicate, bounded scan, count semantics), src/workspace/global-defaults.ts (resolve before advancing the index)
  • Unhandled rejection on failed jump navigation — openJumpEntry() was invoked as a bare floating promise, so a rejecting leaf.openFile() produced an unhandled rejection while the history index had already advanced, leaving navigation silently failed with nothing surfaced. Now caught and logged. Skipping past unresolvable entries widened this path’s reachability, and its narrow time-of-check/time-of-use window is exactly the deleted-file case.
    • Plugin: src/workspace/global-defaults.ts (rejection handling on the jump opener)

Tests

  • 4 e2e cases in test/specs/animated-cursor-runtime-toggle.e2e.ts. Three failed on the unfixed build with no canvas ever created. The load-bearing one is the third: it stashes the canvas element, reloads an unrelated setting, and asserts the element is the same object — the manager drops its canvas when the last controller deregisters, so surviving element identity is evidence that updateOptions() left existing ViewPlugin instances alone rather than rebuilding them. The fourth extends that across enableUndoTree, enableSnippets and snippetTriggerMode to show the slots are independent. The first case’s precondition, that no canvas exists while the setting is off, passes either way and keeps the rest attributable to the toggle.
  • 2 e2e cases in test/specs/cursor-shapes-runtime.e2e.ts. The animated-cursor case failed on the unfixed build with the painted cursor still 20 px tall where an underline is 2. The fork case is explicitly a control: it passes either way, because that path was already correct, and it is labelled so nobody reads it as a reproduction. Neither can use setPluginSettingAndReload — that helper assigns settings[key], so a dotted key becomes a flat property of that literal name and the real cursorShapes object is never touched. The first version of this spec did exactly that and reported the fix as not working.
  • spike-mutable-array-destroy fixed, after its own diagnostics identified the cause. The Windows-only failure was the spike asserting a guarantee registerEditorExtension never made: the keydown fired from an embedded:table-widget editor, which still had the plugin because embedded editors are handed extensions at construction and are not reconfigured by workspace.updateOptions(). Windows CI had a table cell editor open where Linux did not. It now counts only editors that mechanism governs and focuses the leaf editor explicitly instead of clicking the first .cm-editor in the document. Not a regression — it failed identically on f1b5626.
  • The vim-toggle observer assertion now sums across every reconfigurable editor instead of the active one, and carries an editor inventory into its failure message. The first version sampled only the active editor — the same blind spot that made the Windows spike failure unreadable, reproduced one commit later. Windows runs with two editors and Linux with one, so a leak confined to the second editor would have been reported as clean.
  • 1 e2e case in test/specs/vim-toggle.e2e.ts asserting the fork’s CM6 keydown observer is removed on disable and not duplicated on re-enable, across two cycles. Added while investigating the pre-existing Windows failure in spike-mutable-array-destroy, which shows a ViewPlugin being destroyed while its observer still fires — the same shape as the production vim extension. On Linux the live observer count goes 1 to 0 to 1 with no growth; skipping the updateOptions() in disableVim() makes the assertion report off:1 on:1 off:1 on:1. The spike itself now reports live observer, plugin and editor counts in its failure message, so the next Windows run distinguishes “the state still lists the plugin” from “the key reached a different view”.
  • 3 e2e cases in test/specs/runtime-extension-toggles.e2e.ts close the gap left by the slot work, which had only shown the slots do not disturb each other. Discrimination was established by sabotage — reducing refreshRuntimeExtensionSlots() to the animated-cursor slot alone, leaving setup intact, which reproduces the original defect. Two failed: snippetTriggerMode kept expanding on Tab after switching to completion only, and the undo tree recorded 3 more nodes while disabled. The third passed under sabotage and is labelled a control — createSnippetTabKeymap re-reads enableSnippets on every keypress, so that trigger already self-guarded.
  • test/specs/animated-cursor.e2e.ts strengthened. It was the spec that let #181 live: it enabled the cursor and then asserted setting values and caret positions, never that anything rendered, so it passed on a build where the extension was never installed and no canvas existed. It now reads pixels off the canvas and checks the painted cursor sits within 4 px of coordsAtPos, plus a case asserting nothing is painted while the feature is off. Verified by sabotage: with the extension never installed the movement case fails, while the three settings-only cases still pass — which is precisely the blind spot. Compared in absolute viewport coordinates rather than as a delta, because the canvas is shared between tests and an earlier sample can be paint left behind by another one.
  • 1 unit case in test/unit/animated-cursor.test.ts for the manager singleton: peek returns null after teardown where get would build a replacement. CursorController is not exported, so this covers the contract the call site depends on rather than the call site itself.
  • 7 e2e cases in test/specs/vim-builtin/text-objects-builtin.e2e.ts for #178, written before the fix. Five failed on the unfixed code (di{, di(, di[, dib before the pair, and di( finding a pair on a later line — each returning the buffer unchanged). Two are negative controls that pass on the unfixed code: da{ before the pair, proving the a variant’s forward search was already there, and di( positioned after the only pair, proving the fix does not search backwards. Expected values recorded against nvim --clean.
  • 4 e2e cases in test/specs/surround.e2e.ts for #179, written before the fix and all four observed failing with the buffer unchanged (ysi$b, ysa$b, ysi=b, and ysi$b followed by .). The fourth covers the dot-repeat call site, which resolved text objects through the same built-in-only path. The ysaBb case in test/specs/vim-builtin/surround-golden.e2e.ts is the control for the shadowing rule: it failed with the first version of the fix, which let the registered blockquote aB win unconditionally over the built-in {} block, and drove the built-in fallback.
  • 5 e2e cases in test/specs/lua-keymap-modes.e2e.ts for #180, written before the fix. The visual case failed with line 1 instead of 0 and the operator-pending case deleted both lines (Received: "" instead of "hell world\nsecond line"); both pass with the fix. Two of the five are negative controls that pass on the unfixed code — normal mode with "", proving the mapping loaded at all, and an explicit "n" mapping still leaving visual-mode j as the default down motion, proving the expansion did not leak into mode-specific mappings.
  • A vacuous assertion in test/unit/fengari/53bit-integers.test.ts, found by no-useless-escape once test/ began being linted. The Lua lives in a JS template literal, so [[\"]] collapsed to [["]] before Lua ever saw it and q:find searched for a bare " — which string.format("%q", …) always adds as wrapping quotes. The assertion that %q escapes inner quotes had never run. Confirmed by sabotage (assertion failed! at Lua line 24), then restored.
  • The expect-expect and no-floating-promise fixes were verified across 31 e2e specs (31/31 passing, 18m23s) and the full unit suite. 60 of the repaired unit assertions were individually inverted and observed failing before restoration.
  • 16 real-WASM regression cases in test/unit/treesitter/text-object-ranges.test.ts cover delimiter identity/width, enclosing same-type candidates, all four bold delimiter positions, and blockquote depth/newline boundaries. All 16 were observed failing with candidate filtering and the blockquote fix reverted, including trik instead of strike and unwanted after / more outer selection.
  • 27 real-WASM unit cases in test/unit/fold/metadata.test.ts (23 extraction, provider, placeholder and state-identity cases) and test/unit/fold/bridge-metadata.test.ts (4 bridge lifecycle cases). Removing the column-zero exclusive-end adjustment was confirmed to fail the same-level-heading regression (fold end 21 instead of 12), then restored.
  • 7 unit cases in test/unit/util/key-capture.test.ts covering release on settle, release on abort, abort idempotency, a key arriving after abort, non-interference between two captures, and lease balance across all four exit orderings. The six waitForKey cases in test/unit/easymotion-keypress.test.ts were updated for the { promise, abort } shape.
  • 20 unit cases for the three async-prerequisite fixes: test/unit/lua/vim-v-context.test.ts (6, including a negative control that reproduces the old clear-instead-of-restore behaviour), test/unit/lua/key-broker.test.ts (8, covering single-listener ownership, FIFO delivery, abort idempotency and lease release), and 4 added to test/unit/lua/coroutine-runner.test.ts for abandonment release on destroy, timeout, and pre-registration rejection. 3 e2e cases in test/specs/lua-async-prereq.e2e.ts, driven through vim.schedule because it is already async-capable. The getcharstr case was confirmed failing (Received: "" — every keystroke swallowed) with the abort propagation removed, then passing with it restored.
  • 4 e2e cases in test/specs/lua-require.e2e.ts for #177 (module beside a custom init.lua, dot-separated submodule, vault-root modules still resolving, and resolution from a synchronous keymap callback), written first and confirmed failing against the vault-root-only behaviour. 7 unit cases across test/unit/lua/module-snapshot.test.ts (multi-root indexing, shared budget ordering, one unreadable root not sinking the others) and test/unit/lua/package-require.test.ts (non-vault-root resolution, earlier-root precedence, fall-through, error naming every candidate).
  • 9 unit cases in test/unit/lua/module-snapshot.test.ts (nested walks, dot-directory exclusion, each limit, unreadable files, atomic replacement, listing failure) and 8 in test/unit/lua/package-require.test.ts (snapshot hit bypasses the adapter, resolution from a synchronous caller, init.lua fallback, nested submodule, the snapshot-naming miss message, caching across synchronous calls, adapter fallback still reached by an async-capable caller). The first of the 8 is a negative control asserting that without a snapshot the synchronous path still fails — if it ever passes, the other seven have stopped discriminating.
  • 1 e2e case in test/specs/lua-require.e2e.ts for the reload boundary: a module created at runtime is not requirable, the error names the snapshot and both candidate paths, and a configuration reload makes it resolve. test/specs/lua-plugin-flash-diagnostic.e2e.ts had its characterization assertions flipped — they asserted the async-require blocker that this work removes.
  • 2 e2e cases in test/specs/animated-cursor-scroll.e2e.ts for #181, written before the fix and both observed failing. The tracking case failed with the painted cursor 0 px from its pre-scroll position against a caret that had moved 40 px; the phantom case never reached an empty canvas at all across a 20-sample poll, because a stale cursor keeps the dirty region non-null and so suppresses the manager’s full-canvas clear. Assertions are made against pixels read back from the animated-cursor canvas within the editor’s pane rectangle — the surface the bug report shows — not against settings or caret coordinates.
    • Both carry negative controls that pass on the unfixed code: a cursor is painted before the scroll (otherwise the whole measurement is vacuous), the scroll genuinely moves the caret on screen, the caret genuinely leaves the pane, and scrolling back restores the cursor within 3 px of coordsAtPos() — the last one guards the new cachedRect = null path against killing the cursor outright.
    • Two measurement hazards were found empirically and are encoded in the test: the canvas blinks on a 1200 ms cycle, so a single sample can land in a blink-off frame and miss a phantom that is present; and unrelated transient remnants can sit on the shared canvas until the next full-canvas clear, so “painted right now” is not by itself evidence of a phantom. The phantom check therefore waits for an empty canvas first, then holds for more than one blink cycle.
    • The spec cannot use reloadFeatures() to enable the animated cursor the way test/specs/animated-cursor.e2e.ts does: reloadFeatures() does not rebuild the editor-extension slot, so the canvas ViewPlugin is never installed and no canvas exists. It persists the settings and reloads the plugin instead.
    • The tracking case gained a painted-height assertion after the delta check was found insufficient. The block shape and the character inside it derive their screen positions independently, so a glyph stranded at the pre-scroll position still satisfies a comparison of bounding-box tops — it only extends the box downwards. Observed at 56 px of painted height against a 19 px caret line before the glyph fix.
  • CI pre-fetch gained dirs support and commit-SHA pinning, and flash.nvim is now vendored for the diagnostic spec (scripts/fetch-test-plugins.sh, test/fixtures/test-plugins.json). A missing file or directory now fails the script instead of warning; the fetch step runs before build on Linux, macOS and Windows, so a drifted path fails loudly. test/specs/lua-plugin-flash-diagnostic.e2e.ts skips its flash-dependent cases when the fixture is absent.
  • 11 e2e cases in test/specs/lua-vim-ui.e2e.ts for vim.ui (overridability, E7 keymap-callback invocation, non-blocking return, input cancel vs empty confirm, open contract, reload-while-open teardown, and P1–P4 of the third-party override idiom via test-vault/lua/uiselect_shim.lua); 4 unit cases in test/unit/picker/picker-cancel.test.ts; 12 in test/unit/lua/decoration-provider.test.ts; 6 in test/unit/lua/extmarks.test.ts; 4 in test/unit/lua/api-compat.test.ts.
  • 11 unit tests in test/unit/lua/fn.test.ts (Unicode index conversion, wincol/winlayout geometry and fallbacks, plugin-facing stub degradation, nvim__redraw guard survival) and test/unit/lua/api.test.ts (vim.bo write round-trip, vim.wo callback/global-fallback/shadow resolution)
  • New unit suites: test/unit/lua/api-compat.test.ts, iter.test.ts, on-key.test.ts, termcodes.test.ts, treesitter-queries.test.ts, and plugin-query-fetch.test.ts. Covers option routes, current handles, iterator semantics, observer lifecycle, byte conversion, real WASM query compilation, resolution/modelines, plugin isolation, cache lifecycle, and limits.
  • Four getwininfo cases added to test/unit/lua/fn.test.ts; corrected test/unit/lua/api.test.ts to expect "\r", not "<CR>", from nvim_replace_termcodes.
  • 20 unit tests in test/unit/lua/extmarks.test.ts for extmark engine (set, get, delete, clear, virtual text, position tracking, range queries)
  • 27 new tests in test/unit/lua/stdlib.test.ts for vim.validate, vim.version, vim.keycode, vim.notify_once
  • 16 new tests in test/unit/lua/fn.test.ts for new vim.fn.* functions
  • 11 new tests in test/unit/lua/api.test.ts for new nvim_* API functions
  • 1 updated test in test/unit/lua/highlight.test.ts for nvim_create_namespace unique IDs
  • 22 unit cases in test/unit/jumplist.test.ts for unresolvable-entry traversal: both directions past one and several consecutive dead entries, all-dead history, index unchanged on exhaustion, peek consistency, count clamping, and large counts terminating in the current file.
  • Rewrote the deleted-file case in test/specs/jump-list.e2e.ts. It previously opened fixtures via obsidianPage.openFile(), which does not record a plugin jump, so the scenario was never established and the assertion resolved against history leaked from earlier specs — making it order-dependent and passing for the wrong reasons. It now clears inherited history, navigates with gd (which does record), asserts the exact [A, B] history before deleting, and deterministically verifies the entry is gone. New fixtures: test-vault/fixtures/jump-list/.
  • Current unit-test snapshot: 102 files, 1987 passed, 6 skipped.

Documentation

  • KNOWN_LIMITATIONS.md: the animated-cursor section records the scroll-tracking fix, why a stale rect could never clear itself, and the pane intersection test
  • CONTRIBUTING.md: controller.ts description covers the scroll listener, the cached-rect drop when the caret leaves the pane, and the intersection test
  • AGENTS.md, CONTRIBUTING.md: the runtime extension-slot mechanism and the rule that a setting gating an extension must be handled in refreshRuntimeExtensionSlots() and appear in a reload path in both settings implementations
  • README.md: animated-cursor resilience list includes scroll tracking
  • AGENTS.md: fork description records the inner-bracket forward pair search and runTextObjectMotion’s registered-object-then-built-in resolution order for ys
  • KNOWN_LIMITATIONS.md: new “Bracket text objects outside the pair” section; the surround parity section records that ys now reaches registered text objects and how shadowed keys resolve
  • docs/features/surround.md: ys accepts the plugin’s Markdown text objects
  • docs/features/text-objects.md: bracket objects find the next pair ahead of the cursor
  • docs/configuration/lua-config.md: new “Mode strings” table for vim.keymap.set, documenting every accepted mode character and the "" expansion, plus a mapping example using it
  • docs/features/text-objects.md, KNOWN_LIMITATIONS.md: delimiter cursor semantics and depth-aware blockquote boundaries also apply to tree-backed selections.
  • AGENTS.md, CONTRIBUTING.md: fold metadata module and tree lifetime contract; corrected the stale pre-rewrite bridge description.
  • docs/features/workspace-navigation.md: parsed heading boundaries, indented headings, fenced-code exclusion and regex fallback.
  • CHANGELOG.md
  • docs/configuration/lua-config.md: corrected the vim.fn.getcharstr() example, which showed the call inside a vim.keymap.set callback — the one context that cannot yield, so the documented snippet always raised. Replaced with the vim.schedule form, plus the ten-second bound and the one-keypress-one-waiter rule
  • KNOWN_LIMITATIONS.md: getcharstr/getchar entry records shared-broker delivery order and the ten-second await bound
  • AGENTS.md, CONTRIBUTING.md: key-broker.ts added to the Lua module trees; coroutine-runner.ts description notes the abandonment hook
  • AGENTS.md: require() resolution rewritten — snapshot-first, async only for runner-managed threads, chunk-argument injection, refresh points; module-snapshot.ts added to the Lua compatibility module tree
  • CONTRIBUTING.md: module-snapshot.ts added to the source tree; package.ts, coroutine-runner.ts, and loader.ts descriptions updated
  • KNOWN_LIMITATIONS.md: new “Module snapshot and require()” section — the reload boundary, refresh points, the four resource limits and their reporting
  • README.md: Lua bullet notes synchronous require() resolution
  • docs/configuration/lua-config.md: new “require() resolves synchronously” section with the lazy-require example, the async-fallback boundary, and a callout for files added after load; new “Where modules are searched” section covering both roots, their precedence, and the desktop-only out-of-vault case
  • NEOVIM_API_STATUS.md: corrected registration totals (audited historical baseline: 63/94/157 and 84/46/130 for vim.api and vim.fn, respectively; real/stub/total with async callbacks), reclassified the “Unlisted API surface” table with verified plugin reachability (REQUIRED/OPTIONAL/GUARD), documented why an unregistered name is worse than a stub, corrected the stale vim.o.eventignore/selection/cmdheight/columns/cpo rows that contradicted the documented resolution order, and refreshed the “Next candidates” matrix
  • AGENTS.md: vim.bo option set, new vim.wo scope, vim.fn count
  • CONTRIBUTING.md: fn.ts description and count
  • README.md: vim.fn count
  • KNOWN_LIMITATIONS.md: reconciled two contradictory vim.fn counts (65 and 79) against the authoritative registry total
  • docs/configuration/lua-config.md: new vim.fn rows, vim.bo table additions and write semantics, new “Window-local options (vim.wo)” section, registered-surface counts
  • AGENTS.md: API counts (60/79), all eight compatibility modules, injection/teardown and query loading architecture, test coverage; retains prior extmark/API documentation.
  • CONTRIBUTING.md: synchronized source tree, API counts, compatibility boundaries, and unit-test conventions.
  • KNOWN_LIMITATIONS.md: corrected termcodes and .scm blocker, corrected the overstated mini.ai claim, documented pre-mapping observation, iterator extensions, query limits/reloads/re-fetching, and remaining Treesitter integration gaps; updated API counts/list; documented navigation-time skipping of unresolvable jump-list entries.
  • README.md: corrected advertised vim.api count 59 → 60 and vim.fn count 77 → 79; expanded the Lua feature bullet and clarified working fork-backed operatorfunc support.
  • docs/configuration/lua-config.md: documented all nine compatibility items, query directory conventions and limits, option defaults, examples, and API counts alongside the earlier API expansion.
  • docs/development/architecture.md: corrected API counts, new compatibility modules, initialization/cleanup ordering, and query loading architecture.

[0.147.0] - 2026-09-05

Added

  • Automated detection for the defect classes that kept escaping review — most recent bugs were found by accident during unrelated work, not by the test suite or by plan review. Plan review validates intent; it structurally cannot observe that a method has no caller, that a parameter is ignored, or that a cleanup loop lacks exception isolation. Four blocking gates now cover code-level properties, and each was validated by confirming it fires on a defect that actually shipped.
    • @typescript-eslint/no-unused-vars now reports unused parameters (args: 'after-used'); the preset had args: 'none', which is why a parameter accepted and then ignored while the behaviour it should have driven was hardcoded survived review (#177). Vendored src/lib/fengari/** is exempt — its signatures mirror the Lua C API.
    • npm run format:check is a CI gate, and Prettier is now a pinned devDependency. It had been resolving through npx at whatever version was current, so formatting was never actually enforced.
    • .ast-grep/rules/promise-owned-listener.yml flags a listener installed inside a Promise executor. Verified against the pre-fix getcharstr implementation at src/lua/loader.ts:1601: the rule fires on it.
    • .ast-grep/rules/unguarded-disposer-loop.yml flags cleanup loops that invoke disposers without isolating exceptions. It found all five instances in the tree, including the destroyState loop where a throwing finalizer would also have skipped lua_close.
    • npm run verify runs lint, format, and pattern checks together; npm run lint:patterns runs the ast-grep scan.

Fixed

  • Modal key capture leaked its listener when abandoned — EasyMotion’s key and label waiters, flash’s label input and jump mode, and hint mode each installed a capture-phase keydown listener inside a Promise executor, so the listener was released only on the paths that settled it. Nothing settles an abandoned Promise, which is how the same shape shipped as a real defect in vim.fn.getcharstr(). Hint mode additionally held the global key-intercept lease across that window, so an abandoned hint session left the editor swallowing keys. All five now go through captureKeys, which releases the listener and everything acquired with it on every exit path, exactly once.

    • Plugin: src/util/key-capture.ts (new), src/easymotion/keypress.ts, src/easymotion/register.ts, src/flash/label-input.ts, src/flash/char-mode.ts, src/flash/jump-mode.ts, src/ui/hint-mode.ts
    • Hint mode’s hintModeActive flag and intercept lease are now acquired and released by the same owner as the listener, so they are balanced by construction rather than by every return path remembering to call cleanup().
    • The five sites had been carrying ast-grep-ignore: promise-owned-listener suppressions since the rule landed. All are deleted; npm run lint:patterns is green without them.
  • A throwing cleanup callback aborted every later cleanup — destroyState and four other teardown paths invoked disposers in a bare loop, so the first throw skipped every remaining disposer and whatever followed the loop. In destroyState that included lua_close, meaning one bad finalizer leaked the entire Lua state. All five now run through runCleanups, which continues past a failure and reports each one.

    • Plugin: src/util/cleanup.ts (new), src/lua/engine.ts, src/lua/autocmd.ts (×2), src/workspace/global-key-handler.ts, src/main.ts
  • Two parameters were accepted and then ignored — surfaced immediately by the new unused-parameter gate, both the same shape as #177. parseInlineContent(text, startIndex) ignored startIndex while its only caller passed inlineNode.startIndex and separately compensated for the offset itself; the parameter is removed rather than implemented. recordSearch(..., opts) threaded a FlashCharOptions through three call sites without reading it.

    • Plugin: src/treesitter/runtime.ts, src/flash/char-mode.ts
    • Three genuinely-unused fixed-arity callback parameters were renamed to the _ convention rather than removed: src/vim/statuscolumn.ts, src/lua/io-shim.ts, src/lua/treesitter/language-tree-api.ts
  • A timed-out vim.fn.getcharstr() swallowed the next keystroke — the key waiter’s cleanup lived in the promise the coroutine runner abandons after 10 seconds, so it never ran: the capture-phase listener stayed installed and key interception stayed on until some later key arrived, which was then consumed instead of reaching the editor. Waiting more than ten seconds at a getcharstr prompt therefore cost the user a keypress with no visible cause. A new key broker owns exactly one listener and one intercept lease for any number of waiters, and yieldWithPromise gained an abandonment hook so the runner releases it on timeout, on destruction, and on the two paths that reject before registering.

    • Plugin: src/lua/key-broker.ts (new), src/lua/coroutine-runner.ts (onAbandon, idempotent release, AsyncRegistry.abandonAll), src/lua/fn.ts (getcharstr/getchar pass the abort), src/lua/loader.ts (broker construction replaces the inline waiter)
    • Also fixes a second defect in the same code: concurrent waiters each installed their own listener, so a single keypress resolved all of them. Delivery is now FIFO, one waiter per key.
  • Overlapping Lua callbacks corrupted vim.v — the context backing vim.v.count, v:register, v:operator and v:event was a module-global that each callback overwrote and then reset to defaults. The damaging case was not mutual overwrite but premature clear: an inner callback completing reset the shared value out from under an outer one still running, which then read v:count == 0. Reachable in production because async autocmd callbacks already run on the coroutine runner. Callbacks now save the displaced context and restore it, and the keymap paths restore only when they actually installed one — they previously cleared unconditionally, wiping a context they never set.

    • Plugin: src/lua/api.ts (setVimVContext returns the previous context, new restoreVimVContext, all four callback sites)
    • The async autocmd path also moved its restore from .then() to .finally(), so a rejection no longer leaves the context installed for every later reader.
  • The Lua coroutine runner was never torn down — runner.destroyAll() had no caller anywhere, so a configuration reload closed the lua_State while the runner still held live coroutine handles and a pending await could resume into it. The runner and key broker are now registered as state cleanups, which destroyState runs before lua_close, and the post-await hook reset is skipped once the runner is destroyed rather than reaching into a closed state.

    • Plugin: src/lua/loader.ts (registerStateCleanup for runner and broker), src/lua/coroutine-runner.ts (guarded lua_sethook in the finally)
  • require() ignored the directory of a custom init.lua path — with Lua config path set to folder/vim/init.lua, a module at folder/vim/lua/regex.lua was unreachable: require("regex") searched only lua/ at the vault root, so a configuration kept in its own folder could not carry its own modules. The module search root was hardcoded, and the configDir argument injectPackageAndRequire already received was never used. require() now searches lua/ beside the configured init.lua first, then lua/ at the vault root, so existing vault-root layouts are unaffected; on a name collision the module beside the configuration wins. (#177)

    • Plugin: src/lua/loader.ts (getModuleRoots, createSnapshotAdapter), src/lua/package.ts (multi-root candidates, RequireDeps.roots), src/lua/module-snapshot.ts (rebuild walks every root under one shared budget), src/util/external-fs.ts (listExternalDir)
    • A lua/ directory beside a configuration outside the vault is searched too, read through the filesystem rather than the vault adapter, and is therefore desktop-only — matching the out-of-vault configuration file itself, which is also unreadable on mobile. A configuration inside the vault works on both.
    • The “not present in the configuration snapshot” error now names every candidate across both roots, so a wrong assumption about the search path is visible in the message.
  • Scroll offset centering misaligns when alternating up/down movement — with high scrolloff values (e.g., set scrolloff=999 for centered scrolling), the cursor’s vertical position drifted ~17px when changing movement direction (move down, stop, move up). Root cause: the updateListener-based scrolloff used if/else if to enforce only one margin (top or bottom) per update cycle. With centering, both constraints are always violated but only one was corrected, causing direction-dependent asymmetry. Fixed by replacing the if/else if with a constraint-based algorithm: convert visual coordinates to document-relative positions, compute minScroll/maxScroll for both margins simultaneously, clamp scrollTop into the valid interval, and use the midpoint when the interval is inverted (high scrolloff centering) for direction-independent symmetric positioning. Also narrowed the update trigger from selectionSet || viewportChanged to selectionSet only — viewport-only changes (e.g., zt/zz/zb scroll commands) should not trigger scrolloff re-adjustment. A 1px dead zone prevents subpixel oscillation. (#176)

    • Plugin: src/vim/scrolloff.ts (constraint-based dual-margin algorithm with coordsAtPos + midpoint, narrowed trigger to selectionSet only)

Tests

  • 1 regression test in test/specs/scrolloff-centering.e2e.ts for #176 (cursor stays centered when alternating up/down movement with scrolloff=100)

Documentation

  • CHANGELOG.md

[0.146.0] - 2026-09-05

Added

  • Table debug state inspector — new :tablestate (:tables) ex command and window.CodeMirrorAdapter.getTableDebugState(app) API that snapshots all hidden table interaction state into a single queryable object. Exposes: table-nav session (WeakMap state, widget connection, scopes, timers), CM6 StateField, mode tracker status, cell editor status, cursor suppression (global, per-view, override count), fork key intercept flag, cell crossing coordination, DOM markers, and table scroll metrics. Designed to make table interaction bugs observable instead of invisible. (#167)
    • Plugin: src/vim/table-debug-state.ts (new — snapshot and formatter)
    • Plugin: src/vim/table-nav-controller.ts (getTableNavSessionSnapshot() export, __DEV__ lifecycle logging)
    • Plugin: src/vim/table-cell-motions.ts (getCrossingState() export)
    • Plugin: src/vim/bundled-vim.ts (registerTableDebugState() lazy registration)
    • Plugin: src/workspace/commands.ts (:tablestate ex command)
    • Plugin: src/main.ts (debug state registration at startup and reload)
    • Plugin: src/types/codemirror-vim.d.ts (fork type declarations for new exports)
    • Fork: src/block-cursor.ts (getViewOverrideCount(), isCursorSuppressed() re-export)
    • Fork: src/index.ts (isKeyInterceptActive(), updated exports)
    • Fork: DIFFERENCES.md (documented new exports)

Fixed

  • Key intercept stuck after switching from Live Preview to source mode during table-nav — setKeyInterceptActive(true) set during table-nav was never cleared when switching LP→source because the TableNavController ViewPlugin doesn’t receive update() calls or destroy() during view reconfiguration. All vim key processing was suppressed globally, leaving the editor unresponsive. Fixed with a window capture-phase keydown safety handler that detects stale key intercept (active but no .vim-motions-table-nav-mode in DOM) and clears it. Pressing Escape restores key processing. Cursor suppression and stale Obsidian Scopes remain as a known limitation requiring fork-level fixes. (#167)
    • Plugin: src/main.ts (window keydown safety handler)
    • Plugin: src/vim/bundled-vim.ts (setKeyInterceptActive and clearCursorSuppressedForView exposed on bridge)
    • Plugin: src/vim/table-nav-controller.ts (forceTableNavCleanup() export, logTableEvent lifecycle logging)
    • Plugin: src/vim/table-debug-state.ts (event log: logTableEvent, getTableEventLog, recentEvents in state output, mainCursorLayerHidden field)
  • Horizontal scrolling missing in table-nav mode — navigating to off-screen columns in wide tables left the highlighted cell outside the visible viewport. The table widget had overflow: visible which prevented horizontal scrolling entirely — no ancestor element was scrollable. Fixed by changing the table widget’s overflow to overflow-x: auto during table-nav mode and scrolling the widget element directly in scrollHighlightedCellIntoView(). (#167)
    • Plugin: src/vim/table-nav-controller.ts (horizontal scroll logic targeting s.widgetEl)
    • Styles: styles.css (.vim-motions-table-nav-mode: overflow-x: auto; overflow-y: visible)
  • Hot-reload and manual reload fail intermittently — when the initial config load hit Obsidian’s adapter timing race (file exists on disk but adapter.read() returns empty during early lifecycle), vimrcWatchPath/luaWatchPath were set to null because found was derived from the read result, not the stat. This broke both the file watcher (never fires) and the reload command (softReloadVimrc bails on null path). Three fixes: (1) watch paths are now resolved via resolveVimrcPath/resolveLuaConfigPath (stat-based) as a fallback when the read-based load returns found: false; (2) softReloadVimrc no longer bails on empty commands — an empty parse is a valid reload (clears all vimrc mappings); (3) reloadAllConfigs resolves vimrcWatchPath on-demand when null. (#168)
    • Plugin: src/main.ts (stat-based watch path fallback in initial load, softReloadVimrc guard relaxed, reloadAllConfigs on-demand resolution)
  • Removed Lua keymaps persist after config reload — keymaps registered via vim.keymap.set() in init.lua were not cleaned up when removed from the config and reloaded. Two root causes: (1) softReloadLuaConfig did not unmap old Lua keymaps or undefine old ex commands before re-loading; (2) Lua keymaps were double-registered (eagerly during loadInitLua and again via applyLuaMaps in reloadFeatures), so a single vim.unmap only removed one entry. Fixed by adding an unmap loop in softReloadLuaConfig that removes all occurrences of each old mapping. (#168)
    • Plugin: src/main.ts (softReloadLuaConfig unmap/undefineEx cleanup with multi-occurrence loop)
  • Scroll offset activates during mouse selection — dragging to select text near the viewport edge triggered scrolloff-based scrolling prematurely. With high scrolloff values (e.g., scrolloff=999 for centered scrolling), the viewport jumped while the mouse was still well within the editor. Fixed by tracking pointer-down state via a CM6 ViewPlugin with eventObservers and suppressing the scrolloff listener while a mouse button is held (plus a 100ms settle delay after release to prevent post-selection scroll drift). (#175)
    • Plugin: src/vim/scrolloff.ts (added mouseTracker ViewPlugin, pointerdown/pointerup observers, document-level pointerup safety listener, 100ms settle timer)
  • Subword motion dw deletes across line boundaries — with subword motions enabled, dw on the last word of a line deleted the newline and text from the next line instead of stopping at end of line. Two root causes: (1) the subword motion mapCommand calls were missing forward: true/forward: false in motionArgs, so the fork’s clipToLine (which prevents dw from crossing line boundaries) was never triggered; (2) the subword motion only recognized word-character boundaries (letters, numbers) and completely skipped non-word non-whitespace characters (:, ., !, etc.), causing w to jump past trailing punctuation to the next line instead of stopping at the punctuation group. Verified against Neovim 0.12.5 — all test scenarios now match Neovim’s behavior. (#174)
    • Plugin: src/motions/register.ts (added forward: true to w/e and forward: false to b/ge subword motion registrations)
    • Plugin: src/motions/subword.ts (added punctuation group boundary detection via PUNCT_GROUP_RE, merged with subword boundaries in getLineStarts/getLineEnds)
  • Hover tooltips from other plugins clipped within scrolloff zone — hover tooltips (page preview, auto-linker, LanguageTool, and any CM6 hoverTooltip extension) were hidden for text within the top/bottom scrolloff lines. Root cause: the scrolloff implementation used EditorView.scrollMargins, which CM6’s tooltip plugin reads to determine the “visible” viewport area — tooltips positioned within the margin zone were clipped to top: -10000px. With the default scrolloff of 5, the first ~5 lines were affected; with scrolloff=999 (centered scrolling), the entire top half of the viewport was affected. mousedown was unaffected because the mouse-down guard already returned null from scroll margins. Fixed by replacing EditorView.scrollMargins with an EditorView.updateListener that manually adjusts scrollDOM.scrollTop after cursor movement, preserving the scrolloff scroll behavior without marking the margin zone as invisible to the tooltip system. (#170)
    • Plugin: src/vim/scrolloff.ts (replaced scrollMargins facet with updateListener-based manual scroll adjustment, added 100ms settle delay after pointerup to prevent scroll drift on mouse selection end)

Tests

  • 3 e2e tests in test/specs/table-cursor-vanish.e2e.ts for #167 (stuck state detection, bridge cleanup, automatic keydown safety handler)
  • 2 e2e tests in test/specs/table-debug-state.e2e.ts for table debug state instrumentation (#167)
  • 3 additional e2e tests in test/specs/config-management.e2e.ts for #168 (removed vimrc mapping cleaned up via reload command; removed vimrc mapping cleaned up on disk change; removed Lua keymap cleaned up via reload command)
  • 2 e2e tests in test/specs/scrolloff-mouse.e2e.ts for #175 (mouse drag near viewport edge should not cause scroll drift; keyboard navigation should still trigger scrolloff)
  • 3 e2e tests in test/specs/subword-motions.e2e.ts for #174 (dw at end of line, dw on last word, dw before trailing punctuation)
  • 6 e2e tests in test/specs/cursor-pointer-events.e2e.ts for #170 (pointer-events, mousemove targeting, Decoration.mark hit-testing, caretRangeFromPoint, mouseover, hoverTooltip callback)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: added horizontal scrolling fix, key intercept stuck limitation, table debug state documentation
  • CONTRIBUTING.md: added table-debug-state.ts to file tree, updated table-nav-controller.ts and table-cell-motions.ts descriptions, updated scrolloff.ts description to reflect new implementation
  • AGENTS.md: updated fork API surface with new diagnostic exports
  • docs/features/tables.md: updated viewport scrolling callout to mention horizontal scrolling

[0.145.0] - 2026-09-04

Added

  • Configuration hot-reload for Lua — the Lua configuration file (init.lua) is now watched for changes. Saving the file triggers a soft-reload that tears down the old Lua state and re-initializes it without a plugin reload, matching the existing vimrc hot-reload behavior. (#168)
    • Plugin: src/main.ts (added Lua file watcher, softReloadLuaConfig() method)
  • New configuration management commands — two new Obsidian commands for managing configuration files:
    • Vim Motions: Reload configuration — reloads both vimrc and Lua config files immediately.
    • Vim Motions: Open configuration in default editor — opens all active configuration files (vimrc and/or Lua) in the system’s default external editor. Desktop only.
    • Plugin: src/main.ts (added reload-configuration and open-configuration commands, reloadAllConfigs() and openConfigInDefaultEditor() methods)

Fixed

  • reload-configuration fails when initial vimrc load missed the file — if the initial vimrc load hit the known adapter timing race (empty file read during early lifecycle), vimrcWatchPath was null and the reload command silently did nothing. Fixed by resolving the vimrc path on-demand in reloadAllConfigs() when vimrcWatchPath is null. (#168)
    • Plugin: src/main.ts (reloadAllConfigs() path resolution fallback)
  • reload-configuration notification shown when notifications disabled — the generic “configuration reloaded” notice was shown when showConfigNotifications was disabled, and was also shown when configMode was settings (no configs to reload). Fixed: generic notice only shows when notifications are suppressed as a minimal confirmation; shows “no configuration files to reload” when neither config type is active. (#168)
    • Plugin: src/main.ts (reloadAllConfigs() notification logic)
  • Non-markdown files missing from :files, :buffers, and buffer navigation — :files only listed .md files (used getMarkdownFiles()), :buffers only listed markdown leaves (checked getViewType() === 'markdown'), and ]b/[b, :b <name>, :find, :bfirst/:blast all had the same markdown-only filters. Canvas, base, and other file-backed views were invisible to all buffer/file commands. Fixed by replacing getMarkdownFiles() with getFiles() for file listing, getViewType() checks with instanceof FileView for leaf iteration, and adding leaf.getRoot() === rootSplit guards to exclude sidebar panel views (backlink, outline, etc.) from buffer commands. (#169)
    • Plugin: src/picker/sources/files.ts (getMarkdownFiles() → getFiles())
    • Plugin: src/picker/sources/buffers.ts (getViewType() → instanceof FileView + rootSplit guard)
    • Plugin: src/workspace/commands.ts (:buffers fallback modal, :find, :b, :bfirst/:blast — same FileView + rootSplit pattern)
    • Plugin: src/ui/global-ex-command.ts (global ex fallbacks: findFile, bufferSwitch, bufferFirstLast)
    • Plugin: src/motions/buffers.ts (renamed getMarkdownLeaves → getFileLeaves, instanceof FileView + rootSplit, removed .endsWith('.md') fallback filter)

Tests

  • 6 e2e tests in test/specs/config-management.e2e.ts for #168 (vimrc reload picks up new mappings; reloaded mapping works functionally; Lua config reload via command; open-configuration registered; reload-configuration registered; open-configuration does not throw)
  • 6 regression tests in test/specs/files-buffers-non-md.e2e.ts for #169 (:files picker includes .canvas files; :buffers picker includes canvas leaves; :find navigates to canvas file; :b switches to canvas leaf; :blast works with canvas leaf present; ]b reaches canvas leaf)

Documentation

  • CHANGELOG.md
  • AGENTS.md: updated with configuration management commands and Lua hot-reload
  • CONTRIBUTING.md: updated src/main.ts description
  • README.md: added hot-reload to feature list
  • docs/configuration/lua-config.md: documented soft-reload and external editor command
  • docs/configuration/vimrc.md: documented reload and external editor commands
  • docs/reference/keybindings.md: added new configuration commands to Obsidian commands table
  • docs/features/quality-of-life.md: added configuration hot-reload section
  • docs/features/index.md: added hot-reload to configuration overview
  • docs/index.md: added hot-reload to feature highlights
  • docs/features/ex-commands.md: clarified :files searches all vault files, not just markdown

[0.144.0] - 2026-09-04

Added

  • Lua os library support — the os library is now available in the Lua sandbox, providing access to date, time, and environment functions. Parity with Neovim: all functions available on desktop, nil on mobile. os.execute and os.exit are permanently blocked for security.
    • Plugin: src/lua/engine.ts (opened os library, added defense-in-depth blocks)

Changed

  • Fengari CJS-to-TypeScript-ESM conversion — all 37 fengari source files converted from CommonJS JavaScript to TypeScript ESM with full typing (strict: true). Broken circular dependencies between defs.ts/luaconf.ts and linit.ts/lualib.ts. Implemented a hybrid TValue type system with type predicates and narrowed accessors.
    • Plugin: src/lib/fengari/*.ts (37 files converted from .js CJS to .ts ESM)
    • Plugin: src/lib/fengari/index.ts (barrel rewritten — no as any casts)
    • Plugin: src/lib/fengari/platform.ts (was .js)
    • Plugin: src/lib/fengari/package.json (removed — CJS marker no longer needed)
    • Plugin: src/lib/fengari/DIFFERENCES.md (updated for TS conversion)
    • Plugin: src/lua/engine.ts (null check for luaL_newstate())
    • Plugin: vitest.config.ts (removed .test.js pattern)
    • Plugin: eslint.config.mts (removed all fengari-specific rule overrides — all suppressions now inline with descriptions)
    • Plugin: src/lib/fengari/lbaselib.ts (console.log → console.warn for Lua print() fallback — dead code in plugin context, avoids community scanner no-console warning)
    • Plugin: src/lua/engine.ts (narrowed lua_atnativeerror handler string coercion)
  • Host-agnostic Lua runtime — replaced platform guards with a dependency injection pattern. Fengari no longer sniffs for Node.js or browser environments, instead using a provider injected by the plugin.
    • Plugin: src/lib/fengari/platform.ts (platform provider implementation)
    • Plugin: src/lua/engine.ts (injected Obsidian platform provider)

Tests

  • 23 unit tests in test/unit/fengari/ (6 fork-specific converted to typed TS, 17 upstream absorbed from fengari repo)
  • 13 unit tests in test/unit/lua/os-lib.test.ts for the os library

Documentation

  • CHANGELOG.md
  • AGENTS.md: updated fengari section — TypeScript ESM conversion, TValue type system, test organization
  • CONTRIBUTING.md: updated fengari directory description
  • KNOWN_LIMITATIONS.md: updated absorbed fengari references and links
  • docs/configuration/lua-config.md: updated absorbed fengari references

[0.143.0] - 2026-09-03

Fixed

  • Disabling workspace navigation disables editor ex commands and core vim actions — turning off Settings → Vim Motions → Navigation → Workspace navigation also disabled all editor-level ex commands (:w, :q, :buffers, :sp, :vs, :grep, etc.) and all core vim actions registered by registerWorkspaceNavigation() — including fold commands (zc/zo/za/zM/zR/zj/zk/[z/]z/zf/zd/zE), paste (P/gp/gP), goto-definition (gd/gD/<C-]>), document outline (gO), URL open (gx), character info (ga/g8/K), file info (<C-g>), blank lines (]<Space>/[<Space>), horizontal scroll (zs/ze/zH/zL), and more. Root cause: registerExCommands() and registerWorkspaceNavigation() were both inside the if (enableWorkspaceNav) gate. Fixed by (1) moving registerExCommands() outside the gate, (2) splitting registerWorkspaceNavigation() into registerCoreVimActions() (always called — editor commands unrelated to pane/tab management) and registerWorkspaceNavigation() (gated — pane focus, tab switching, splits, close). (#165)
    • Plugin: src/main.ts (registerExCommands and registerCoreVimActions called unconditionally; registerWorkspaceNavigation remains gated)
    • Plugin: src/workspace/navigation.ts (split into registerCoreVimActions + registerWorkspaceNavigation)
  • Disabling hard-wrap disables fold commands — turning off Settings → Vim Motions → Vim features → Hard-wrap formatting also disabled all fold commands (zf/zF/zd/zD/zE/zv/zj/zk/[z/]z/zn/zN/zi + :fold/:foldopen/:foldclose/:folddoopen/:folddoclosed). Root cause: registerFoldCommands() and registerFoldEnableCommands() were called inside registerOperators(), which is gated by enableHardWrap. Fixed by moving both fold registration calls out of registerOperators() and calling them unconditionally in main.ts.
    • Plugin: src/operators/register.ts (removed fold registration calls)
    • Plugin: src/main.ts (registerFoldEnableCommands and registerFoldCommands called unconditionally)
  • keyToKey mappings silently fail when prefix-deferred — vim.keymap.set("n", "<leader><leader>", ":buffers<CR>") with vim.g.mapleader = " " silently did nothing because <Space><Space>h (hint mode) created a partial match, triggering the fork’s prefix-ambiguity deferral. After the operatorshadowtimeout (1000ms), the deferred command timer called commandDispatcher.processCommand() which has no handler for keyToKey type mappings — they fell through to default: break. Fixed by adding a keyToKey type check in the deferred command timer that routes to doKeyToKey() instead, matching the dispatch logic in the main findKey path. The 1-second delay before the deferred command fires is correct Neovim timeoutlen behavior — users who want a shorter delay can use set timeoutlen=300 (now a supported alias for operatorshadowtimeout). (#166)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (_deferredCommandTimer callback: keyToKey → doKeyToKey; timeoutlen/tm registered as aliases for operatorshadowtimeout; timeoutlen=0 executes immediately instead of blocking)
    • Plugin: src/vim/neovim-options.ts (removed timeoutlen/tm from noop list — now handled by the fork)
    • Plugin: src/vimrc/loader.ts (added timeoutlen/tm to KNOWN_SET_OPTIONS as aliases for operatorshadowtimeout)
    • Plugin: src/lua/api.ts (vim.opt.__newindex now syncs non-sideEffect KNOWN_SET_OPTIONS to the fork via callbacks.setOption)
    • Plugin: src/ui/which-key.ts (suppress vim-keypress after vim-command-done to prevent which-key popup re-showing after synchronous command execution)

Tests

  • 3 regression tests in test/specs/settings-reload.e2e.ts for #165 (:buffers ex command works with workspace nav disabled; gO outline works with workspace nav disabled; zj fold motion works with hard-wrap disabled)
  • 2 regression tests in test/specs/settings-reload.e2e.ts for #166 (<Space><Space> keyToKey mapping executes after operatorshadowtimeout when <Space><Space>h partial exists; timeoutlen=0 executes deferred mapping immediately)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: marked workspace navigation paste dependency as fixed; updated operator-prefix key dispatch section with timeoutlen/tm aliases, keyToKey dispatch fix, and timeoutlen=0 immediate execution
  • CONTRIBUTING.md: updated navigation.ts and operators/register.ts descriptions to reflect split
  • AGENTS.md: updated fork description with keyToKey deferred command fix and timeoutlen/tm aliases
  • docs/configuration/settings.md: updated operatorshadowtimeout description with timeoutlen/tm aliases
  • docs/configuration/vimrc.md: updated operatorshadowtimeout short names with timeoutlen/tm aliases
  • docs/configuration/lua-config.md: updated operatorshadowtimeout entry with timeoutlen/tm aliases

[0.142.0] - 2026-09-03

Fixed

  • :obcommand via vim mapping in visual mode loses selection — when a user maps a visual-mode key to :obcommand editor:toggle-bullet-list (or any selection-dependent Obsidian command) via exmap + vmap, the command operates on only the cursor line instead of all selected lines. Root cause: the codemirror-vim fork’s ExCommandDispatcher._processCommand() exits visual mode before executing the ex command handler, so by the time :obcommand calls executeCommandById(), the CM6 selection is collapsed. Fixed by reading the visual range from params.selectionLine/params.selectionLineEnd (set by parseInput_ before exitVisualMode) and falling back to the '</'> vim marks for the exmap indirection path where the inner _processCommand no longer has visual mode context. The CM6 selection is expanded to the visual range before the Obsidian command executes. (#161)
    • Plugin: src/workspace/commands.ts (added getVisualRange() and expandSelectionFromRange() in createObCommand)
    • Plugin: src/types/vim-api.d.ts (added selectionLine/selectionLineEnd to ExCommandArgs)

Tests

  • 3 e2e tests in test/specs/obcommand-visual-mode.e2e.ts for #161 (direct handleEx bullet toggle, defineEx exmap indirection, numbered list variant — all in visual-line mode)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: updated Obsidian command passthrough section with ex command path fix

[0.141.0] - 2026-09-03

Changed

  • E2E test Obsidian version pinned to 1.13.7 — wdio.conf.mts browserVersion changed from 'latest' to '1.13.7'. Obsidian 1.13.8 is a mobile-only release (APK only, no desktop asar), causing the test runner to fail with “No compatible installers available.”

Fixed

  • Disabling workspace navigation disables all global keybindings — turning off Settings → Vim Motions → Navigation → Workspace navigation also disabled the : ex command line in reading view, vim.obsidian.keymap.set mappings, and hint mode global hotkeys. Root cause: the GlobalKeyHandler and GlobalMappingRegistry were only instantiated when enableWorkspaceNav was true, and all three interception gates (shouldInterceptContent, shouldInterceptHints, shouldInterceptStructural) returned false when the setting was disabled. Fixed by always creating the global key handler on desktop and moving the enableWorkspaceNav guard from the interception layer to the mapping registration layer — only scroll, tab, and pane navigation keys are conditional on the setting; :, hint mode, and user global keymaps are always registered. (#164)
    • Plugin: src/workspace/global-key-handler.ts (removed enableWorkspaceNav guard from shouldIntercept* methods)
    • Plugin: src/workspace/global-defaults.ts (added opts.enableWorkspaceNav parameter to conditionally register workspace-nav-specific keys)
    • Plugin: src/main.ts (always create GlobalKeyHandler/GlobalMappingRegistry on desktop)
  • Hint mode <leader><leader>h mapping reappears after settings change — vim.keymap.del("n", "<leader><leader>h") in init.lua was silently undone by any subsequent settings change because reloadFeatures() re-registered the hardcoded mapping without re-applying Lua map operations. Additionally, the mapping was registered without an explicit context: 'normal', making mode-specific vim.keymap.del unable to target it (codemirror-vim’s unmap requires exact context match). Fixed by (1) calling applyLuaMaps() at the end of reloadFeatures() so Lua unmaps are re-applied after built-in mappings, and (2) registering the hint mode mapping with { context: 'normal' }. (#162)
    • Plugin: src/main.ts (applyLuaMaps call in reloadFeatures, context: 'normal' on hint mode mapCommand)
  • Leader-based mapCommand calls missing context: 'normal' — picker (<leader>ff/fg/fb/...), harpoon (<leader>ha/hp/hn/hN, <leader>1–<leader>9), and workspace navigation (<leader>rn/rb/ra, grn/grr/gra) leader mappings were registered without an explicit mode context. This made vim.keymap.del("n", ...) unable to remove them because codemirror-vim’s unmap(lhs, 'normal') requires context === 'normal' to match. Added { context: 'normal' } to all leader-based action mapCommand calls. EasyMotion motions were intentionally left without context because they must work in operator-pending and visual modes (d<leader><leader>w, v<leader><leader>j).
    • Plugin: src/main.ts (picker and harpoon leader mappings)
    • Plugin: src/workspace/navigation.ts (<leader>rn/rb/ra and grn/grr/gra mappings)
  • vim.keymap.del unable to remove context-less mappings per-mode — Vim.unmap(lhs, 'normal') silently failed on mappings created by mapCommand() or :map without a mode prefix, because the entry had context === undefined and the strict equality check (undefined === 'normal') never matched. The only removal paths were removeMapCommand(keys) or unmap(lhs, undefined), both of which remove from all modes. Fixed in the fork: unmap now falls back to splitting a context-less entry into per-mode entries for the remaining modes when a mode-specific unmap is requested, matching Neovim’s :nunmap on a :map-created mapping. Also fixed mapclear’s context-splitting mode set from ['normal', 'insert', 'visual'] to ['normal', 'visual', 'operatorPending'] to match Neovim’s :map semantics (:map covers normal+visual+operatorPending, not insert).
    • Fork: ~/Repos/codemirror-vim/src/vim.js (unmap context-less entry splitting, mapclear mode set fix)
  • Command palette lags ~1 second in visual-line mode — opening the command palette from visual-line mode caused a visible delay. Root cause: the wrapCheckCallback wrapper called withExpandedSelection for every command’s checkCallback(true) availability check, dispatching two CM6 transactions (expand + restore) per command. With 200+ commands, this produced 400+ synchronous CM6 dispatches while the palette rendered. Fixed by skipping withExpandedSelection for the checking path (checkCallback(true)) — the VisualLineSomethingSelectedPatch already ensures somethingSelected(), getCursor(), and listSelections() return the correct visual-line range, so availability checks work without native CM6 selection expansion. The executing path (checkCallback(false)) still uses withExpandedSelection. (regression of #157, #163)
    • Plugin: src/vim/visual-line-command-fix.ts (skip withExpandedSelection for checkCallback(true))
  • Stale visual-line selection cache causes RangeError — lastVisualLineSel and pendingVisualLineSel cached line numbers from a previous visual-line selection could exceed the document length after the editor content was replaced with a shorter document (e.g., between e2e tests or :e commands). The stale cache caused doc.line(N) to throw RangeError: Invalid line number N in M-line document. Fixed by validating cached selection line numbers against the current document length before use; stale caches are invalidated instead of producing errors.
    • Plugin: src/vim/visual-line-command-fix.ts (isSelInBounds validation in somethingSelected, getSelection, replaceSelection)

Tests

  • 3 regression tests in test/specs/settings-reload.e2e.ts for #164 (global key handler/registry existence with workspace nav disabled) and #162 (Lua keymap deletions survive reloadFeatures)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: updated workspace navigation section to reflect decoupled architecture; updated yank-ring workspace navigation dependency note; updated three-gate description; updated keymap.del + Q deviation and removeMapCommand description
  • KNOWN_LIMITATIONS.md: updated wrapCheckCallback description with checkCallback(true) optimization (#163)
  • AGENTS.md: updated fork description with unmap per-mode splitting and mapclear mode set fix
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md: added “Per-mode unmap of context-less mappings” section; updated removeMapCommand description

[0.140.0] - 2026-09-03

Fixed

  • Picker pick_keymap ignores Alt and other modifier keys — vim.obsidian.pick_keymap() only recognized the C- (Ctrl) modifier prefix. Key specs with A- (Alt), S- (Shift), M- (Meta), or modifier combinations like C-A-j were silently ignored. Root cause: matchesPickerKey() had a simple C- check and fell through to plain key matching for everything else. Replaced with a proper modifier parser (parsePickerKeySpec) that strips all modifier prefixes and validates each against the corresponding KeyboardEvent flags. (#159)
    • Plugin: src/picker/types.ts (rewrote matchesPickerKey with full modifier support)
  • Global workspace navigation missing Shift and Meta modifier normalization — normalizeKeyEvent() only produced <C-x> and <A-x> notation. <S-Tab>, <M-f>, and modifier combinations like <C-S-Tab> or <C-A-f> were never generated, so user-defined global mappings with those prefixes could not match keyboard events. Added <S-> normalization for special keys (Tab, Enter, Space, etc.), <M-> for Meta-only, and combined prefix support in canonical order (C-, A-, M-, S-).
    • Plugin: src/workspace/global-mapping-registry.ts (rewrote normalizeKeyEvent with full modifier prefix generation)
  • Hint mode missing altKey in result — waitForHintKey() captured ctrlKey, metaKey, and shiftKey from the label selection event but discarded altKey. Added altKey to the HintResult interface and all three resolve sites, making Alt-modified hint actions possible for future extensions.
    • Plugin: src/ui/hint-mode.ts (added altKey to HintResult interface and all resolve sites)
  • Subword motions skip non-ASCII Unicode characters — w/b/e/ge with subword motions enabled skipped Arabic, CJK, accented Latin, and other non-ASCII text instead of stopping at word boundaries. Root cause: isWordChar() used /[A-Za-z0-9]/ and SUBWORD_RE only matched ASCII letter patterns. Fixed by replacing all character classification with Unicode property escapes (\p{L}, \p{Lu}, \p{Ll}, \p{N}, \p{M}) and adding a ([\p{L}\p{M}]+) alternative for caseless scripts (Arabic, Hebrew, CJK, Devanagari). The long-line fallback regex (WORD_SEGMENT_RE) was also updated. (#160)
    • Plugin: src/util/subword.ts (Unicode-aware SUBWORD_RE and isWordChar())
    • Plugin: src/motions/subword.ts (Unicode-aware WORD_SEGMENT_RE fallback)
  • EasyMotion word targets skip non-ASCII Unicode characters — EasyMotion w/b/e word motions did not generate jump labels on Arabic, CJK, or other non-ASCII words. Root cause: WORD_START_RE used \b\w and WORD_CHARS_RE used \w+, both ASCII-only. Fixed with [\p{L}\p{M}\p{N}]+ using the u flag. (#160)
    • Plugin: src/easymotion/targets.ts (Unicode-aware WORD_START_RE and WORD_CHARS_RE)

Tests

  • 29 unit tests in test/unit/picker/picker-keymap.test.ts for matchesPickerKey modifier support (Alt, Shift, Meta, combinations, multi-spec matching) (#159)
  • 4 e2e tests in test/specs/picker-modifier-keys.e2e.ts for Alt-j/Alt-k picker navigation via pick_keymap Lua config (#159)
  • 21 unit tests in test/unit/global-mapping-registry.test.ts for normalizeKeyEvent modifier normalization (Ctrl, Alt, Meta, Shift, special keys, combinations)
  • 6 unit test cases in test/unit/subword.test.ts for Unicode boundary/end detection (Arabic, mixed ASCII+Arabic, CJK, accented Latin)
  • 6 regression tests in test/specs/subword-motions.e2e.ts for Unicode subword motions (w/b/e/dw on Arabic, mixed text, CJK) (#160)
  • 5 regression tests in test/specs/easymotion-comprehensive.e2e.ts for Unicode EasyMotion word targets (w/e/b on Arabic and CJK) (#160)

Documentation

  • CHANGELOG.md
  • docs/features/ex-commands.md: updated picker key format description to document A-, S-, M- modifier prefixes and combinations
  • docs/configuration/remapping.md: updated picker key format description and added Alt-j/Alt-k example
  • README.md: updated subword motions description to mention Unicode support
  • docs/reference/keybindings.md: updated subword motions table to note Unicode support

[0.139.0] - 2026-09-03

Added

  • Plugin auto-fetch system — vim.plugins.add() now supports automatic fetching of Neovim plugins from GitHub. Downloads tarball archives, extracts them to lua/, and manages a lock file (lua/.plugin-lock.json) for version pinning. Supports branch, tag, and commit pinning. Atomic staging writes to lua/.staging/ ensure vault integrity. Configurable via pluginAutoFetch setting (Advanced page).
    • Plugin: src/lua/plugin-fetch.ts (download and extraction logic)
    • Plugin: src/lua/plugin-store.ts (atomic writes and lock file management)
    • Plugin: src/lua/tar.ts (synchronous tar parser)
    • Plugin: src/lua/api.ts (extended vim.plugins.add with fetch triggering)
    • Plugin: src/lua/loader.ts (wired fetch callback)
    • Plugin: src/settings.ts (new pluginAutoFetch setting)
  • require() init.lua fallback — require("name") now tries lua/name/init.lua if lua/name.lua is not found. Matches Neovim’s module resolution behavior and enables multi-file plugins.
    • Plugin: src/lua/package.ts (updated package.path and search logic)

Changed

  • Lua sandbox: rawget/rawset/rawequal re-enabled — these standard Lua functions are now available in the sandbox, matching Neovim’s Lua environment. Previously disabled as a sandboxing measure, but this prevented vim.is_callable from detecting callable tables and broke compatibility with Neovim plugins that use rawget/rawset.
    • Plugin: src/lua/engine.ts (removed from disabled globals list)

Fixed

  • Operator-pending mode context — modeToContext('o') now correctly returns 'operatorPending' instead of 'normal'. Fixes keymap collisions where operator-pending text objects shadowed normal mode mappings (e.g., gc/gcc).
    • Plugin: src/lua/api.ts (context mapping fix)
  • Fork: Backtracking dispatch — the codemirror-vim fork now supports backtracking when a longer partial match fails. Deferring a shorter full match (e.g., flash s motion) for a longer partial (e.g., surround s<char>) no longer loses the motion if the partial match fails or times out. Replays the suffix via doKeyToKey.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (backtracking logic in handleKeyNonInsertMode)
  • Fork: Backtracking deferral exclusion rules — the backtracking deferral in matchCommand no longer falsely defers built-in keys (i, a, s, S, d, y, c, <, >) when text-object motions, linewise shortcuts, surround actions, or <leader> keymaps share a string prefix. Four exclusion rules prevent false positives: _isDefault entries (built-in keymaps), special-key false prefix (< vs <leader>), motions extending non-motions (il vs i), and operatorPending actions without an active operator (s<char> vs s). Without this fix, pressing i or a in normal mode was deferred by ~1 second instead of immediately entering insert mode.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (exclusion rules in matchCommand)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (documented all 4 exclusion rules)
  • Lua function-callback keymaps survive view plugin recreation — applyLuaMaps reverted to use this.registration?.defineAction() instead of vim.defineAction() directly. The VimRegistration persistence layer re-applies action registrations when the CM6 view plugin is recreated (e.g., during vim mode toggle), preventing Lua vim.keymap.set function callbacks from silently stopping.
    • Plugin: src/main.ts (reverted applyLuaMaps function-callback path)

Tests

  • CI test plugin pre-fetch — e2e tests no longer depend on runtime GitHub fetches. A scripts/fetch-test-plugins.sh script downloads plugin tarballs before tests run, driven by test/fixtures/test-plugins.json. The CI workflow fetches plugins in a dedicated step on all platforms (Linux, macOS, Windows). loadMiniComment skips vim.plugins.add() when the plugin is pre-fetched; the fetch test is skipped when pre-fetched.
  • Updated test/specs/lua-plugin-mini-comment.e2e.ts — loadMiniComment detects pre-fetched plugins and skips vim.plugins.add(); fetch test guarded with this.skip() when pre-fetched.
  • Added pluginAutoFetch to excluded settings in test/unit/known-set-options.test.ts.
  • Added vimHandleKeysSync helper in test/helpers.ts — dispatches keys via Vim.handleKey in a single synchronous executeObsidian call with Escape prefix. Supports waitForTimeout flag for leader-key mappings that trigger Neovim-correct operatorshadowtimeout deferral.
  • Updated test/specs/lua-vim-v.e2e.ts to use vimHandleKeysSync with waitForTimeout=true for <leader>t/<leader>h mappings that have longer partials (table nav keymaps \tL, \tdd, etc.).
  • Updated test/specs/lua-doc-examples.e2e.ts smart-go-to-top test to use vimHandleKeysSync with waitForTimeout=true.
  • Updated test/specs/lua-require.e2e.ts rawget/rawset tests — expectations changed from 86/87 to 90 to reflect intentional re-enabling.

Documentation

  • CHANGELOG.md
  • AGENTS.md: added vimHandleKeysSync to test helpers list, added fork backtracking description, added CI test plugin pre-fetch description
  • CONTRIBUTING.md: updated src/lua/ file tree with new fetch-related files, added test plugin pre-fetch documentation
  • KNOWN_LIMITATIONS.md: marked plugin fetching as implemented; added init.lua fallback note; updated vim.is_callable note (rawget no longer restricted); updated operator-prefix key dispatch section with backtracking deferral
  • README.md: added plugin auto-fetch to features list
  • docs/configuration/settings.md: added pluginAutoFetch setting
  • docs/configuration/lua-config.md: updated vim.plugins.add and require() sections

[0.138.0] - 2026-09-02

Added

  • Tier 1 vim.api expansion — 27 new nvim_* functions (43 total), unlocking the mini.nvim plugin ecosystem. New functions organized in 4 waves:
    • Cursor + line + marks: nvim_get_current_win, nvim_get_current_line, nvim_set_current_line, nvim_win_get_cursor, nvim_win_set_cursor, nvim_buf_get_mark, nvim_buf_set_mark, nvim_buf_del_mark
    • Global keymaps + key injection: nvim_set_keymap, nvim_del_keymap, nvim_get_keymap, nvim_replace_termcodes, nvim_feedkeys
    • Commands + stubs + options: nvim_command, nvim_del_user_command, nvim_win_get_buf, nvim_get_current_tabpage, nvim_buf_get_option, nvim_buf_set_option, nvim_get_option, nvim_set_option
    • Variables + messaging + text: nvim_buf_get_var, nvim_buf_set_var, nvim_echo, nvim_buf_set_text
    • Plugin: src/lua/api.ts (27 function registrations, requireWindowZero validator, updated metatable)
    • Plugin: src/lua/loader.ts (8 new callbacks: getCmAdapter, getMarkPos, setMark, delMark, getLine, setLine, replaceRange)

Fixed

  • Table-nav Tab/Shift+Tab stops at row boundaries — pressing Tab at the last cell of a row now wraps to the first cell of the next row, and Shift+Tab at the first cell wraps to the last cell of the previous row. Matches Obsidian’s native table Tab behavior. At the absolute first/last cell of the table, Tab/Shift+Tab stay in place. The navigate() method gains a wrap parameter; h/l keys retain their non-wrapping behavior. (#158)
    • Plugin: src/vim/table-nav-controller.ts (wrapping logic in navigate(), forwarded wrap parameter in actions lambda and cell-edit scope)
    • Plugin: src/vim/table-nav-keymap.ts (wrap parameter in TableNavActions interface, Tab handler passes wrap=true)

Tests

  • 29 new unit tests in test/unit/lua/api.test.ts for all new nvim_* functions (92 total)
  • 4 regression tests in test/specs/table-nav-mode.e2e.ts for Tab/Shift+Tab row wrapping (#158)

Documentation

  • CHANGELOG.md
  • AGENTS.md: updated vim.api function count to 43 with expanded category list
  • CONTRIBUTING.md: updated src/lua/api.ts description and buffer/autocmd/highlight file descriptions
  • KNOWN_LIMITATIONS.md: 6 new known limitations for Lua API (character offsets, deprecated options, feedkeys flags, echo, handles, replace_termcodes)
  • README.md: updated vim.api.* description
  • docs/configuration/lua-config.md: full API reference tables for all new functions
  • docs/features/tables.md: updated Tab/Shift+Tab description to mention row wrapping
  • docs/reference/keybindings.md: updated Tab description to mention row wrapping

[0.137.1] - 2026-09-02

Fixed

  • vitest.config.ts — properly bundle treesitter WASM for testing.

[0.137.0] - 2026-09-01

Added

  • vim.treesitter API — full treesitter integration backed by web-tree-sitter (WASM), running as a parallel parser alongside CM6’s Lezer. Markdown and HTML grammars are bundled; the subsystem activates on-demand when Lua code calls get_parser(). Provides the complete Neovim vim.treesitter API surface:
    • Core: get_parser, get_string_parser, get_node, get_node_text, get_range, get_node_range, is_in_node_range, is_ancestor, node_contains, get_captures_at_pos, get_captures_at_cursor
    • TSNode: 31 methods (type, parent, child, named_child, field, start, end_, range, sexpr, equal, iter_children, named_children, descendant_for_range, and more)
    • TSTree: root, copy, included_ranges
    • LanguageTree: 18 methods (parse, trees, lang, children, parent, root, for_each_tree, register_cbs, node_for_range, tree_for_range, language_for_range, invalidate, destroy, and more)
    • Query engine: query.parse, query.get, query.set, Query:iter_captures, Query:iter_matches, Query:disable_capture, Query:disable_pattern
    • 8 built-in predicates: #eq?, #match?, #vim-match?, #lua-match?, #contains?, #any-of?, #has-ancestor?, #has-parent? with generic #not-*/#any-* prefix handling
    • 4 built-in directives: #set!, #offset!, #gsub!, #trim!
    • Custom predicates/directives: query.add_predicate, query.add_directive, query.list_predicates, query.list_directives
    • Language management: language.register, language.get_lang, language.get_filetypes, language.add (async via coroutine bridge), language.inspect (ABI version, fields, symbols, supertypes)
    • Stubs (present, won’t error): start, stop, foldexpr, select, inspect_tree
    • Plugin: src/treesitter/runtime.ts, src/treesitter/bridge.ts, src/treesitter/query.ts, src/treesitter/predicates.ts, src/treesitter/directives.ts, src/treesitter/language-tree.ts, src/treesitter/injection.ts, src/treesitter/types.ts, src/treesitter/wasm.d.ts
    • Plugin: src/lua/treesitter/api.ts, src/lua/treesitter/node.ts, src/lua/treesitter/tree.ts, src/lua/treesitter/language.ts, src/lua/treesitter/query-api.ts, src/lua/treesitter/language-tree-api.ts, src/lua/treesitter/range.ts
    • Grammars: src/treesitter/grammars/tree-sitter-markdown.wasm, src/treesitter/grammars/tree-sitter-html.wasm
  • Treesitter-enhanced Markdown features — core plugin features now use treesitter for structural parsing when available, with regex fallback:
    • Fold provider: treesitter section node hierarchy for heading fold boundaries (replaces heading-level regex)
    • Fold placeholder: treesitter-based heading text and code language extraction
    • Heading navigation (]h/[h): getAllNodesOfType(view, 'atx_heading') replaces line-by-line regex scan; automatically skips headings inside fenced code blocks
    • Code block text objects (iC/aC): findContainingNodeOfType(view, row, col, 'fenced_code_block') replaces full-document fence scan
    • Blockquote text objects (iB/aB): findContainingNodeOfType(view, row, col, 'block_quote') with correct nesting depth (fixes nested diB scoping)
    • Delimiter text objects (i*/a*, i_/a_, i`/a`, i$/a$, i~/a~): inline grammar nodes (emphasis, strong_emphasis, code_span, latex_block, strikethrough) for correct nested delimiter boundaries
    • Snippet context detection: treesitter-based code/prose/frontmatter detection (O(1) vs O(n) document scan)
    • Plugin: src/text-objects/code-block.ts, src/text-objects/blockquote.ts, src/text-objects/delimiter.ts, src/motions/headings.ts, src/snippets/context.ts, src/fold/provider.ts, src/fold/placeholder.ts
  • Markdown inline grammar — bundled tree-sitter-markdown-inline.wasm (416KB) for inline content parsing. Provides structural nodes for emphasis, strong emphasis, code spans, inline links, strikethrough, LaTeX, HTML tags, and backslash escapes.
    • Plugin: src/treesitter/grammars/tree-sitter-markdown-inline.wasm
    • Plugin: src/treesitter/runtime.ts (parseInlineContent, getInlineNodeAtPosition)
  • JS-side treesitter API — TypeScript helper functions for querying treesitter trees from plugin code (not Lua):
    • Position lookup: getNodeAtPosition, getNamedNodeAtPosition, getInlineNodeAtPosition
    • Ancestor queries: hasAncestorOfType, findAncestorOfType, isInsideNodeType, findContainingNodeOfType
    • Inline nodes: findContainingInlineNodeOfType, isInsideInlineNodeType
    • Navigation: findNextNodeOfType, getAllNodesOfType
    • Full queries: queryCaptures
    • Plugin: src/treesitter/js-api.ts, src/treesitter/tree-state.ts
  • Fork: setTokenClassifier hook — codemirror-vim fork now exposes Vim.setTokenClassifier(fn) for host-provided token classification. The % bracket matcher uses the classifier to skip brackets inside inline code spans, improving Markdown bracket matching accuracy.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (setTokenClassifier, moveToMatchedSymbol, surround match path)
    • Plugin: src/main.ts (installTokenClassifier)

Changed

  • Fold golden tests re-recorded — fold-motions golden data re-recorded with vim.treesitter.foldexpr() active and luaSetup forcing synchronous treesitter parse + fold recomputation before each test case. Corrected 2zk golden value that was affected by nvim_feedkeys async timing (verified against normal! behavior). All 12 fold motion golden cases now have correct Neovim reference values.

Fixed

  • Note Composer extract fails in visual-line mode — Note Composer’s “Extract current selection” (and Note Refactor) failed in visual-line mode when invoked via the command palette or right-click context menu. Two root causes: (1) Note Composer uses getCursor('from')/getCursor('to') and listSelections() to determine the replacement range, but these returned a collapsed cursor-only range in visual-line mode — the text was read correctly via getSelection() but the replacement was inserted at a zero-width cursor position without removing the original lines. (2) The withExpandedSelection wrapper’s finally block restored cursor-only CM6 selection before the async modal completion callback ran, and handleExternalSelection in codemirror-vim exited visual mode when it saw the collapsed selection. Fixed by patching getCursor('from'/'to') and listSelections() to return the visual-line expanded range; adding a pendingVisualLineSel WeakMap snapshot set before expandSelection() that survives async modal flows (30 s TTL, consumed on first use by replaceSelection); and extending the fallback chain in somethingSelected(), getSelection(), and replaceSelection() to include lastVisualLineSel and pendingVisualLineSel. (#157, regression of #137/#138)
    • Plugin: src/vim/visual-line-command-fix.ts (pendingVisualLineSel WeakMap snapshot; getCursor/listSelections patches; lastVisualLineSel fallback chain in somethingSelected/getSelection/replaceSelection)
  • zj/zk skip nested heading folds — zj now visits all foldable lines including nested headings (e.g., ## Heading inside a # Heading section), matching Neovim behavior. Previously findNextFoldable/findPrevFoldable had a parentRange filter that excluded child folds contained within a parent fold’s range. Closes 6 fold motion Neovim deviations (zj, 2zj, zk, 2zk, [z, ]z).
    • Plugin: src/fold/motions.ts (removed parentRange filter from foldNext/foldPrev, fixed foldPrev count search to use fold start instead of fold end)

Tests

  • 2 regression tests in test/specs/visual-line-command.e2e.ts (#157): real command palette toggle numbered list in V-LINE (opens actual command palette, types command, selects it); real command palette Note Composer extract in V-LINE (end-to-end: opens command palette, selects “Extract current selection”, enters filename in Note Composer’s modal, verifies selected lines removed and link inserted, cleans up extracted file)
  • 97 unit tests for treesitter subsystem: test/unit/treesitter/runtime.test.ts (12), test/unit/treesitter/api.test.ts (36), test/unit/treesitter/query.test.ts (19), test/unit/treesitter/language-tree.test.ts (16), test/unit/treesitter/js-api.test.ts (22 — including 8 inline node detection tests)
  • 12 e2e tests for treesitter Lua API: test/specs/treesitter.e2e.ts
  • Re-recorded golden data: test/neovim/golden-data/fold-motions.json (with luaSetup forcing treesitter parse before fold motions)
  • 6 fold motion Neovim deviations removed from test/neovim/deviations.ts

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: updated visual-line selection section with getCursor/listSelections patches, pendingVisualLineSel WeakMap snapshot, and fallback chain description (#157); updated test coverage count (4 → 6 tests); updated trade-off section to list all 5 patched Editor methods
  • AGENTS.md: treesitter architecture, new file descriptions, Lua API surface update, fork setTokenClassifier hook
  • CONTRIBUTING.md: treesitter file tree in src/ structure, tree-state.ts added
  • KNOWN_LIMITATIONS.md: treesitter integration section with 7 known limitations
  • README.md: treesitter feature in Lua configuration description
  • docs/configuration/lua-config.md: updated API availability note

[0.136.0] - 2026-09-01

Added

  • 36 new vim.fn functions — expanded the Lua API from 29 to 65 supported vim.fn.* functions:
    • Register: setreg(regname, value [, options]), getreg(regname), getregtype(regname) — full register manipulation matching Neovim’s signatures
    • Buffer: setline(lnum, text), append(lnum, text|list), indent(lnum), nextnonblank(lnum), prevnonblank(lnum) — buffer modification and line scanning
    • Position: getpos(expr), setpos(expr, list), cursor(lnum, col), getcurpos() — cursor and mark manipulation; getpos("'[") / getpos("']") enables g@ operatorfunc usage
    • Type/introspection: type(expr), len(expr), empty(expr) — fundamental type guards for Lua configs
    • Pattern matching: matchstr(s, pat), match(s, pat [, start]), matchlist(s, pat) — string pattern matching (ECMAScript regex)
    • String/list: escape(s, chars), repeat(s|list, count), reverse(s|list), range(n [, end [, stride]]), sort(list), uniq(list), max(list), min(list), abs(n), index(list, item), count(list, val)
    • List/dict sugar: add(list, item), remove(list, idx), extend(list1, list2), copy(expr), deepcopy(expr), keys(dict), values(dict), items(dict), flatten(list) — syntactic sugar over Lua builtins / vim.tbl_* equivalents
    • Plugin: src/lua/fn.ts (all implementations + VimFnCallbacks extended with setCursor, getMarkPos, setMark, setLine, insertLines)
    • Plugin: src/main.ts (wired new callbacks in executeLuaForTest)
    • Plugin: src/lua/loader.ts (wired new callbacks in main loader)
  • Neovim-compatible foldopen option — fold-aware navigation now matches Neovim’s foldopen semantics. Each vim motion is tagged with a category (hor, block, jump, mark, search, percent, undo), and only motions whose category is in the configured foldopen set trigger auto-unfold when the cursor enters a folded range. Plain vertical motions (j/k) have no category and never unfold, matching Neovim’s intentional exclusion of vertical movements. Configurable via set foldopen=block,hor,mark,percent,search,undo (Neovim default) or set fdo=all. (#155)
    • Fork: ~/Repos/codemirror-vim/src/cm_adapter.ts (foldopenAnnotation CM6 transaction annotation, _pendingFoldopen on adapter)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (40+ motions tagged with foldopen category in defaultKeymap, annotation set in evalInput, undo/redo, jumpListWalk)
    • Fork: ~/Repos/codemirror-vim/src/types.ts (FoldopenCategory type, foldopen on MotionArgsPartial)
    • Fork: ~/Repos/codemirror-vim/src/index.ts (exported foldopenAnnotation, FoldopenCategory)
    • Plugin: src/vim/fold-sync.ts (annotation-gated foldAwareNavExtender, setFoldopen()/getFoldopen()/shouldUnfold())
    • Plugin: src/vim/bundled-vim.ts (injects fork annotation at extension creation)
    • Plugin: src/motions/register.ts (structural motions tagged block, subword motions tagged hor)
    • Plugin: src/vim/options.ts (set foldopen=… / set fdo=… vim option)
    • Plugin: src/main.ts (foldopen handler in applySettingOverride)
    • Plugin: src/settings.ts (updated fold-aware navigation descriptions in both settings tabs)
  • Range-aware fold ex commands — :{range}fold, :{range}foldopen[!], :{range}foldclose[!], :{range}folddoopen {cmd}, and :{range}folddoclosed {cmd} now support Neovim-style line ranges. Previously :foldopen/:foldclose only operated at the cursor. :{range}fold creates a manual fold; ! suffix opens/closes recursively; :folddoopen/:folddoclosed execute an ex command on lines that are not/are in a closed fold.
    • Plugin: src/fold/commands.ts (range-aware handlers: foldRangeEx, foldOpenRangeEx, foldCloseRangeEx, foldDoOpenEx, foldDoClosedEx)
    • Plugin: src/workspace/navigation.ts (removed exCommandFromAction registrations for :foldopen/:foldclose/:foldtoggle — replaced by range-aware versions in fold/commands.ts)
    • Plugin: src/main.ts (executeLuaForTest sandbox handleExCommand wired to vim.handleEx — enables golden tests to create folds via vim.cmd)
  • Neovim options registry — every Neovim option (378 total from src/nvim/options.lua) is now recognized by name in both :set (vimrc) and vim.opt (Lua). Options are classified into tiers: implemented options work normally, hardcoded options log an info note (e.g., set magic — “always on”), deferred options log that support is planned, platform-handled options are accepted silently (e.g., set mouse=a, set encoding=utf-8, set noswapfile), and truly unknown options still produce a warning — enabling typo detection (set mose=a warns, set mouse=a is silent).
    • Plugin: src/vim/neovim-options.ts (new file — comprehensive registry with tier classification)
    • Plugin: src/vimrc/loader.ts (integrated registry, replaced KNOWN_CM_VIM_OPTIONS set, tiered logging)
    • Plugin: src/lua/api.ts (integrated registry into vim.opt/vim.o getter and setter)
  • 12 configurable Neovim options — standard Neovim options previously hardcoded in the codemirror-vim fork are now user-configurable via set/vim.opt with Neovim-compatible defaults and abbreviations:
    • Search: ignorecase/ic (default on), smartcase/scs (default on), hlsearch/hls (default on), incsearch/is (default on), wrapscan/ws (default on)
    • Substitute: gdefault/gd (default off — :s g flag inverts when on)
    • Motion: startofline/sol (default on), whichwrap/ww (default b,s), virtualedit/ve (default empty — supports onemore, all, block)
    • Editing: joinspaces/js (default off — J inserts double space after .!?), shiftround/sr (default off — >>/<< round to shiftwidth), nrformats/nf (default bin,hex — <C-a>/<C-x> format support, octal now available)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (12 defineOption() calls, search call sites updated, hlsearch gating, incsearch gating, wrapscan boundary messages, gdefault inversion, startofline in H/M/L/G/gg, whichwrap in moveByCharacters, virtualedit in clipCursorToContent, joinspaces in joinLines, shiftround in indent operator, nrformats with parseNumberMatch helper)
    • Plugin: src/vimrc/loader.ts (12 options registered in KNOWN_SET_OPTIONS with _fork: prefix settingsKey)
  • Oil preview window (<C-p>) — toggle a side-by-side preview split showing the file under the cursor. Auto-updates on cursor movement via EditorView.updateListener. Second <C-p> closes the preview. Closes automatically when oil closes. New ex command :oilpreview (:oilpre).
    • Plugin: src/oil/manager.ts (togglePreview(), closePreview(), installPreviewCursorListener())
    • Plugin: src/oil/keybindings.ts (<C-p> mapping + oilPreview action)
    • Plugin: src/oil/oil-view.ts (<C-p> scope key registration)
  • Oil visual mode multi-select — V + select lines + <CR> opens all selected file entries. First file replaces the oil leaf, subsequent files open in new tabs. Matches oil.nvim’s visual select behavior.
    • Plugin: src/oil/manager.ts (getVisualRangeEntries(), openMultipleEntries())
  • Oil hidden toggle guard — g. toggle is now blocked when the oil buffer has unsaved changes, showing a notice instead. Matches oil.nvim’s toggle_hidden() behavior which warns and refuses when modified buffers exist.
    • Plugin: src/oil/manager.ts (hasUnsavedChanges() guard in toggleHidden(), return type changed to boolean)
    • Plugin: src/oil/keybindings.ts (checks return value before refreshing)

Fixed

  • Oil: unable to create files with a new directory — creating a file like newfolder/notes.md where newfolder/ doesn’t exist now creates both the directory and the file, matching oil.nvim’s behavior. Root cause: app.vault.create() does not auto-create intermediate directories (unlike app.vault.createFolder()). (#154)
    • Plugin: src/oil/actions.ts (ensureParentDirs() — walks path segments and creates missing directories before file creation)

Tests

  • Oil nested path creation e2e tests — 3 new e2e tests in test/specs/oil-poc.e2e.ts verifying nested file creation (newfolder/notes.md), deeply nested paths (a/b/deep.md), and nested directory creation (parent/child/). All 3 fail before the fix and pass after. (#154)
  • Oil nested path parser unit tests — 4 new unit tests in test/unit/oil-parser.test.ts for nested path parsing.
  • Oil preview toggle e2e test — 1 new e2e test verifying <C-p> opens a preview split and second <C-p> closes it.
  • Oil hidden toggle guard e2e test — 1 new e2e test verifying g. is blocked when buffer has unsaved changes.
  • Oil visual mode multi-select e2e tests — 2 new e2e tests: visual mode detection in oil editor, and multi-file open via visual select + <CR>.
  • Ex command range golden tests — 8 new golden test cases recorded against Neovim 0.12.5 verifying fork-native :{range} support for commands handled entirely by the codemirror-vim fork: :1,3y (yank range), :2,4y a (yank range to named register), :1,3j (join range), :2,4j! (join range without spaces), :put (put after current line via :1y + :put), :3put (put after addressed line via :1y + :3put), :2,4g/pattern/d (global with range prefix), :1,3v/pattern/d (vglobal with range prefix).
    • Definitions: test/neovim/test-definitions.ts (8 new cases in ex-commands-expanded and ex-global suites)
    • Golden: test/neovim/golden-data/ex-commands-expanded.json (re-recorded, 27 cases)
    • Golden: test/neovim/golden-data/ex-global.json (re-recorded, 5 cases)
  • :d count/address and :j range deviations resolved — :d3 (delete with count argument), :$d (delete with $ address), :1,3j (join with range), and :2,4j! (join bang with range) were marked as deviations. All now work correctly: :d{count} parses the count from args[0]; :$d resolves via the fork’s parseLineSpec_; :j range fix changes repeat: lineEnd - line to repeat: lineEnd - line + 1; :j! now passes keepSpaces: true via params.argString bang detection. All 4 deviations removed.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (exCommands.join: off-by-one fix + keepSpaces bang support)
    • Deviations: test/neovim/deviations.ts (2 upstream-unsupported entries removed: :d3, :$d)
  • Foldopen unit tests — 36 tests covering setFoldopen() parsing, shouldUnfold() for every individual category, all/empty sets, custom combinations, and backward-compatible setFoldAwareNavigation().
    • Test: test/unit/foldopen.test.ts
  • Foldopen golden tests — 8 golden cases recorded against Neovim 0.12.5 covering j/k/3j/G/gg/5G with manual folds. 7 match golden data exactly, 1 has a minor column-preservation difference (CM6 preserves visual column on fold-skip; Neovim resets to 0).
    • Test: test/specs/vim-builtin/foldopen-golden.e2e.ts
    • Golden: test/neovim/golden-data/foldopen.json
    • Definitions: test/neovim/test-definitions.ts (new foldopen suite)
    • Deviations: test/neovim/deviations.ts (1 infra-limitation entry for column difference)
  • vim.fn e2e tests — 35 tests in test/specs/lua-vim-fn.e2e.ts covering all new vim.fn functions across 6 categories: registers (setreg, getreg, getregtype), buffer modification (setline, append, indent), position/cursor (nextnonblank, prevnonblank, cursor, setpos, getpos, getcurpos), type/introspection (type ×5, len ×2, empty), pattern matching (matchstr, match, matchlist, escape), string/list utilities (repeat, reverse, range, sort, uniq, max, min, abs, index, count).
    • Test: test/specs/lua-vim-fn.e2e.ts
  • Fold-aware navigation e2e tests — expanded from 2 to 6 tests. New tests verify j/k do not auto-open folds, j from fold line skips past the fold, k navigates back to fold line, and upward k traversal preserves all folds.
    • Test: test/specs/fold-navigation.e2e.ts

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: updated vim.fn function count (27 → 65), added not-yet-implemented table (lower-value/niche functions), added explicitly excluded functions (map/filter/printf incompatible signatures); search options now configurable; unknown options warning behavior updated for Neovim options registry; added “Oil.nvim parity gaps” section documenting 8 unimplemented oil.nvim features; added :oilpreview to oil keybindings list; updated oil status line
  • README.md: oil explorer description updated with <C-p> preview, visual mode multi-select, nested path creation
  • docs/reference/keybindings.md: added <C-p> (:oilpreview) and g? (:oilhelp) to oil keybindings table
  • docs/features/oil-explorer.md: added preview, visual mode multi-select, nested path creation, and hidden toggle guard sections; added :oilpreview to ex commands table
  • docs/features/ex-commands.md: added :oilpreview and :oilhelp to oil ex commands table
  • docs/configuration/lua-config.md: added 36 new vim.fn functions to quick-reference and detailed tables; added 12 new Neovim options to vim.opt table
  • AGENTS.md: vim.fn count updated (27 → 65), fork API surface updated with foldopenAnnotation and 12 configurable Neovim options
  • CONTRIBUTING.md: fn.ts description updated (26 → 65 functions); added neovim-options.ts to src/vim/ file tree
  • docs/development/architecture.md: fn.ts function count updated
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md: :join range/bang/cursor section expanded; added “Configurable Neovim options” section documenting 12 new defineOption() calls
  • README.md: folding feature description updated with foldopen semantics; quality of life section updated with 12 configurable Neovim options and Neovim option compatibility
  • docs/configuration/settings.md: fold-aware navigation description updated, foldopen row added to Workspace navigation settings
  • docs/configuration/vimrc.md: foldopen/fdo added to string options table, foldawarenavigation description updated; added 12 new Neovim options to boolean and string tables; updated unknown options warning description for Neovim options registry
  • docs/configuration/lua-config.md: foldopen added to vim.opt options table
  • docs/features/workspace-navigation.md: fold-aware navigation section rewritten for foldopen semantics
  • docs/features/ex-commands.md: added :{range}fold, :{range}foldopen[!], :{range}foldclose[!], :folddoopen, :folddoclosed; updated :foldopen/:foldclose descriptions to note range support
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md: foldopenAnnotation API section added

[0.135.0] - 2026-08-31

Added

  • Vim toggle commands — three new Obsidian commands to toggle the plugin’s vim mode on/off at runtime without reload: toggle-vim-mode, enable-vim-mode, and disable-vim-mode. Uses a mutable extension array for zero-latency switching. (#153)
    • Plugin: src/main.ts (mutable extension array, toggle methods, Obsidian commands, vimEnabled guards)
    • Plugin: src/settings.ts (new vimEnabled setting in General page)
    • Fork: ~/Repos/codemirror-vim/src/index.ts (exported resetForkedVimState())
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (exported resetCursorState())
  • Lua ex command cleanup — track and remove Lua-defined ex commands during toggle or reload.
    • Plugin: src/lua/loader.ts (ex command name tracking)

Changed

  • Vim subsystem lifecycle — extracted setupVimSubsystems() and teardownVimSubsystems() for clean runtime toggling.
    • Plugin: src/main.ts (lifecycle extraction)
  • Bundled vim state reset — bundledActive is now resettable to support runtime re-initialization.
    • Plugin: src/vim/bundled-vim.ts (resettable state)

Fixed

  • Native cursor visibility after vim toggle — BlockCursorPlugin.destroy() now correctly restores native caret-color and cursor layer display, preventing an invisible cursor when vim is disabled.
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (restore logic in destroy())
  • this.register() accumulation — fixed a bug where reloadFeatures() would accumulate listeners on every call.
    • Plugin: src/main.ts (cleanup logic)

Tests

  • Vim toggle e2e suite — 27 new tests covering full toggle lifecycle, idempotent commands, edge cases (insert/visual mode, debounce), persistence, and bridge availability.
    • Test: test/specs/vim-toggle.e2e.ts (17 tests)
    • Test: test/specs/vim-toggle-resilience.e2e.ts (10 tests)
  • Mutable array spike — validated the updateOptions() mechanism for runtime extension swapping.
    • Test: test/specs/spikes/spike-mutable-array-destroy.e2e.ts (3 tests)
  • Regression detection — added a beforeSuite hook to wdio.conf.mts that cycles vim mode before every spec.
    • Test: wdio.conf.mts (toggle-cycle hook)

Documentation

  • CHANGELOG.md
  • AGENTS.md: updated fork API surface with resetForkedVimState, resetCursorState, BlockCursorPlugin.destroy() cursor restoration; documented vim toggle capability in dual-vim architecture; added beforeSuite toggle-cycle hook and new test files to test infrastructure section; added vimEnabled to settings documentation
  • CONTRIBUTING.md: documented setupVimSubsystems()/teardownVimSubsystems() extraction pattern and mutable extension array mechanism
  • README.md: added vim toggle commands to Quality of life features
  • docs/configuration/settings.md: added vimEnabled setting to General page
  • docs/reference/keybindings.md: added Obsidian commands section with toggle-vim-mode, enable-vim-mode, disable-vim-mode
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md: documented resetForkedVimState API, resetCursorState API, and BlockCursorPlugin.destroy() cursor restoration fix

[0.134.0] - 2026-08-30

Changed

  • Oil ex commands show guard notice outside Oil buffer — Oil-specific ex commands (:oilopen, :oilparent, :oilclose, etc.) are now registered eagerly at plugin startup instead of lazily on first Oil focus. When invoked outside an Oil buffer, they show a descriptive notice (Oil: :<command> only works inside an Oil buffer. Use :Oil to open the file explorer.) instead of silently no-oping or producing the generic “not an editor command” error. (#152)
    • Plugin: src/oil/keybindings.ts (eager registration with guard wrapper, new registerExCommands() public method)
    • Plugin: src/main.ts (call registerExCommands() at startup and settings reload)

Tests

  • 1 new e2e spec file test/specs/oil-ex-guard.e2e.ts — verifies all 11 unique Oil ex commands show a guard notice when invoked outside an Oil buffer, and that commands are recognized (no “not an editor command” error). (#152)

Documentation

  • CHANGELOG.md
  • docs/features/oil-explorer.md: added note about guard notices for Oil ex commands outside Oil
  • docs/features/ex-commands.md: added note about Oil ex command scope
  • docs/reference/keybindings.md: updated Oil section note about ex command behavior outside Oil

[0.133.0] - 2026-08-29

Fixed

  • Snippet completion menu not appearing on line 1 — the autocompletion popup did not show when typing snippet triggers on the first line of a newly created note in Live Preview. Root cause: CM6’s tooltip layer never completed its measure/write pass when the editor was freshly mounted (e.g., opening a new file) — the tooltip DOM element was created but stayed at the pre-measurement sentinel position (top: -10000px). The bundled autocomplete fork’s tooltip, provided via the shared showTooltip facet, was not repositioned because the tooltip layer’s initial geometry measurement was skipped during the editor mount/layout settling phase. Additionally, a standalone tooltips({ parent: document.body }) extension conflicted with Obsidian’s own tooltip configuration. Fixed by: (1) removing the redundant hasFocus guard in the completion source, (2) removing the conflicting tooltips({ parent: document.body }) extension, and (3) adding a MutationObserver-based ViewPlugin that detects stuck tooltips at -10000px and force-positions them using coordsAtPos. (#151)
    • Plugin: src/snippets/completion-source.ts (removed hasFocus guard)
    • Plugin: src/main.ts (removed tooltips({ parent: document.body }), added tooltip positioning nudge ViewPlugin)

Tests

  • 1 new e2e spec file test/specs/snippets/snippet-completion-menu.e2e.ts — creates a new file via app.vault.create(), opens it in Live Preview, types a snippet prefix on line 1, and verifies the completion menu is visually positioned on screen (not stuck at -10000px). (#151)

Documentation

  • CHANGELOG.md

[0.132.0] - 2026-08-29

Added

  • Global config directory search — new globalConfigSearch setting (default off, desktop only) that auto-searches the Obsidian user data folder (~/.config/obsidian/ on Linux, ~/Library/Application Support/obsidian/ on macOS, %APPDATA%\obsidian\ on Windows) for config files after exhausting vault-root candidates. Vault-root files always take priority. Uses getObsidianUserDataDir() (Electron userData path) for platform-correct resolution. (#150)
    • Plugin: src/settings.ts (new globalConfigSearch setting, declarative + imperative UI, RELOAD_KEYS)
    • Plugin: src/lua/loader.ts (resolveLuaConfigPath global search step, LoadInitLuaOptions update)
    • Plugin: src/vimrc/loader.ts (resolveVimrcPath global search step, loadVimrc signature update)
    • Plugin: src/main.ts (passes globalConfigSearch to both loaders and softReloadVimrc)
  • g@{motion} operatorfunc support — new operator that calls a stored callback with the motion type ('line', 'char', or 'block'). Sets '[ and '] marks to the range covered by the motion. Public API includes setOperatorfunc(fn) and getOperatorfunc().
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new operatorfunc operator, setOperatorfunc/getOperatorfunc API)
  • :move and :copy ex commands — proper implementation of line-move and line-copy commands. :move (:m) moves a range of lines to a destination address; :copy (:co, :t) copies them. Supports absolute, relative, and boundary addresses (0, $).
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new :move and :copy ex command implementations)
    • Plugin: src/workspace/commands.ts (cursor positioning fix for :move)
  • Insert mode undo control — added <C-G>u to manually insert an undo break and <C-G>U to suppress the next automatic undo break on cursor movement.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new <C-G>u and <C-G>U actions)
  • Insert mode line navigation — added <C-G>j, <C-G>k, <C-G><C-J>, and <C-G><C-K> to navigate lines while preserving the insert-start column.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new insert-mode navigation actions)
  • Insert mode indent deletion — added 0<C-D> and ^<C-D> to delete all indentation on the current line.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new insert-mode indent actions)
  • Lua vim.o.operatorfunc bridge — wired operatorfunc through the Lua API. Users can assign a Lua function to vim.o.operatorfunc which will be called when g@ is used.
    • Plugin: src/lua/api.ts (Lua bridge for operatorfunc)
    • Plugin: src/types/vim-api.d.ts (updated VimApi type definition)

Changed

  • @: repeat last ex command — now pushes the command to history before execution and supports count prefixes (e.g., 2@: replays twice).
    • Fork: ~/Repos/codemirror-vim/src/vim.js (history push in handleEx, count support)
  • <C-y> and <C-e> cursor behavior — cursor now advances after copying a character from above or below, matching Neovim.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (cursor advancement fix)
  • gR virtual replace at EOL — fixed boundary check to allow replacing the last character of a line.
    • Fork: ~/Repos/codemirror-vim/src/cm_adapter.ts (boundary check fix)
  • <C-a> re-insert previous insert — now correctly saves changes to previousInsertModeChanges before resetting, ensuring the previous insert is always available.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (recording fix in recordLastEdit)
  • Command index overhaul — expanded coverage tracking from 337 to 427 commands. 90 new entries added, 15 commands reclassified as tested, and 4 deviations removed.
    • Test: test/neovim-command-index.yaml (90 new entries)
    • Test: test/neovim/deviations.ts (4 removed, 8 added)

Fixed

  • :move cursor positioning — cursor now correctly lands on the last moved line instead of the first.
    • Plugin: src/workspace/commands.ts (positioning fix)
  • @: e2e test assertion — strengthened assertion from toContain to toBe for more reliable verification.
    • Test: test/specs/vim-builtin/new-commands.e2e.ts (assertion fix)
  • Documentation: incorrect shared config directory instructions — the “Shared config across vaults” sections in lua-config.md and vimrc.md presented global paths like ~/.config/obsidian/init.lua as if they were auto-searched, but they only work when explicitly set as a custom path. Rewritten to document both the new global search toggle (Option A) and the custom absolute path (Option B). (#150)
  • Documentation: tablewidget default wrong in Lua config reference — documented default was "cursor", actual default is "native". Valid values corrected to "native", "raw".
  • Documentation: oilconfirmdeletethreshold default wrong — documented as 5 in lua-config.md and vimrc.md, actual default is 1 (from DEFAULT_SETTINGS).
  • Documentation: Oil vimrc/Lua option names wrong in settings reference — oilexplorer → oil, oilshowhiddenfiles → oilhiddenfiles, oildefaultsort → oilsort. Oil confirm delete threshold range corrected from 1–20 to 0–100.
  • Documentation: yank highlight duration range wrong — documented as 50–3000 ms in settings.md, actual range is 0–5000 ms.
  • Documentation: foldenable listed as Settings UI toggle — foldenable is a CM Vim built-in option registered via vim.defineOption, not a VimMotionsSettings property. Moved from Vim features table to “Vimrc / Lua only” section.

Tests

  • 68 new Neovim golden test cases — expanded coverage for gE, go, *, #, (, ), ]], [[, ][, [], dgn, cgn, visual mode operators, <C-r> redo, gR, gp, gP, Y, insert mode <C-r>, <C-c>, <C-G>u, <C-G>j/k, 0<C-D>, ^<C-D>, and Q.
    • Test: test/neovim/test-definitions.ts (68 new cases)
  • New golden spec file — test/specs/vim-builtin/new-commands-golden.e2e.ts adds 12 golden tests for @:, &, ]<Space>, [<Space>, and insert mode <C-y>, <C-e>, <C-a>.
    • Test: test/specs/vim-builtin/new-commands-golden.e2e.ts (new file)

Documentation

  • CHANGELOG.md
  • AGENTS.md: updated fork API and test infrastructure descriptions
  • CONTRIBUTING.md: updated file tree for new test file
  • KNOWN_LIMITATIONS.md: marked :move and :copy as implemented, updated infra-limitation deviations
  • README.md: added g@ and new ex commands to features list
  • docs/reference/keybindings.md: added new insert mode and editing commands
  • docs/features/ex-commands.md: added :move and :copy details
  • docs/configuration/lua-config.md: added vim.o.operatorfunc documentation, fixed shared config section, tablewidget default, oilconfirmdeletethreshold default
  • docs/configuration/vimrc.md: fixed shared config section, oilconfirmdeletethreshold default
  • docs/configuration/settings.md: fixed oil vimrc/Lua names, yank highlight duration range, moved foldenable to vimrc/Lua-only section, added globalConfigSearch setting
  • AGENTS.md: updated Lua configuration page ownership with globalConfigSearch

[0.131.0] - 2026-08-28

Fixed

  • Animated cursor consumes 50-60% GPU at idle — the rAF animation loop ran at 60-120fps continuously whenever the editor had focus because this.active = animating || this.view.hasFocus always evaluated to true. The heartbeat timer (500ms setInterval) also re-woke the loop every 500ms if it stopped, defeating any idle-stop optimization. Implemented a 4-phase GPU optimization: (1) fixed the active flag to only reflect animation state (active = animating), (2) added a 3-gear frame governor (hot: rAF at ~60fps during animation, warm: setTimeout 600ms for blink toggle, stopped: no scheduling), (3) dirty-rect canvas clearing (only clear the cursor’s bounding box instead of full viewport), (4) per-frame overhead reduction (cached getComputedStyle, matchMedia, accent color; mutable physics quad to eliminate 20 object allocations per tick). Idle GPU usage drops from 60-120 rAF/sec to ~1.67 rAF/sec (97-99% reduction). (#148)
    • Plugin: src/vim/animated-cursor/manager.ts (3-gear frame governor with hot/warm/stopped states, heartbeat stall detection via lastLoopTime, dirty-rect tracking via markDirty()/snapshotDirtyRegion(), DPR change listener, MutationObserver for theme detection, sizeCanvas() moved to ResizeObserver)
    • Plugin: src/vim/animated-cursor/controller.ts (active = animating, cached block char info per position, accent color refresh on theme change only, needsBlink() for warm gear scheduling)
    • Plugin: src/vim/animated-cursor/physics.ts (mutable targetQuad via updateQuadFromRect(), eliminates per-frame allocations)
    • Plugin: src/vim/animated-cursor/config.ts (cached prefers-reduced-motion media query with change listener)
    • Plugin: src/vim/animated-cursor/types.ts (needsBlink() and didDraw() added to Tickable interface)

Tests

  • Animated cursor idle rAF rate measurement — empirical e2e test that instruments requestAnimationFrame to count callbacks over a 5-second idle window with animated cursor enabled. Verifies rAF rate is well below continuous 60fps (before: 825 rAF/5sec = 165/sec; after: 8 rAF/5sec = 1.6/sec — 99% reduction). (#148)
    • Test: test/specs/animated-cursor.e2e.ts (rAF counter via patched window.requestAnimationFrame, 5-second idle measurement, threshold assertion < 150)
  • Table-nav hotkey test uses platform-correct modifier on macOS — the Ctrl+P regression test for table-nav hotkey passthrough (#146) hardcoded Key.Control, which does not open the command palette on macOS (where the shortcut is Cmd+P). The test now uses obsidianPage.getPlatform() and sends Key.Command on macOS, Key.Control elsewhere.
    • Test: test/specs/table-nav-hotkeys.e2e.ts (platform-aware modifier key via obsidianPage.getPlatform())

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: updated animated cursor section — marked rAF loop death as enhanced with gear system, added GPU optimization details
  • docs/features/animated-cursor.md: updated cross-platform resilience section with gear system details

[0.130.0] - 2026-08-27

Fixed

  • Vimium-style hint labels overlap on adjacent elements — When two clickable elements were positioned close together (e.g., a link and a collapse icon in the backlinks sidebar), their hint labels rendered at nearly identical coordinates, making the first label inaccessible behind the second. Root cause: showHints() positioned each label at the target element’s top-left corner with no collision detection. Added resolveOverlaps() — an AABB collision detection pass (same pattern used by EasyMotion’s overlay) that tracks placed label bounding boxes and nudges overlapping labels downward. (#144)
    • Plugin: src/ui/hint-mode.ts (added resolveOverlaps() function, called after initial label creation in showHints())
  • Obsidian hotkeys blocked inside table-nav mode — Ctrl+P (command palette), Ctrl+S (save), and all other Obsidian hotkeys were silently swallowed while the table-nav overlay was active. Three compounding issues: (1) both the nav scope and cell-edit scope were created with new Scope() (no parent), disconnecting Obsidian’s global hotkey bindings from the keymap resolution chain — unhandled keys were dropped instead of falling through to global hotkeys. (2) The keymap handler’s default case returned true, telling the scope handler that every non-table-nav key was consumed. (3) The scope handler called stopImmediatePropagation() on all consumed keys, preventing other DOM listeners from seeing them. Fixed by parenting both scopes to app.scope, returning false for unhandled keys, and removing stopImmediatePropagation. (#146)
    • Plugin: src/vim/table-nav-controller.ts (parent both Scope instances to this.app.scope, remove stopImmediatePropagation)
    • Plugin: src/vim/table-nav-keymap.ts (default: return true → return false)
  • Vc (visual-line change) deletes extra line and mispositions cursor — V to select a line then c deleted the trailing newline along with the line content, merging the current line with the next. When the next line was empty, it was deleted entirely. The cursor ended up on the start of the next line instead of staying on the current (now-empty) line. Root cause: the fork’s change operator had no dedicated linewise visual branch — the catch-all visual path called cm.replaceSelections(['']) with the CM6 selection that had been expanded to include the start of the next line (head = Pos(line+1, 0)), deleting through the newline. Fixed by adding a dedicated args.linewise branch that uses cm.replaceRange('', from, to) to clear line content only (col 0 to end of last selected line), preserving newlines, and positions the cursor at (startLine, 0). (#145)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (operators.change — new args.linewise branch for visual-line change)
  • zz, zt, zb scroll to wrong positions with visible frontmatter properties — In Live Preview mode, when YAML frontmatter was rendered as a properties widget, zt scrolled to center instead of top, zz overshot center, and zb was similarly mispositioned. Root cause: the codemirror-vim fork’s charCoords(pos, 'local') method used contentDOM.getBoundingClientRect() as the vertical coordinate reference, but scrollTo() operates on scrollDOM. In Obsidian, the .metadata-container widget sits inside scrollDOM but outside contentDOM, creating a vertical offset between the two reference points. Fixed by splitting the coordinate reference: contentDOM for horizontal (goalColumn tracking for gj/gk), scrollDOM + scrollTop for vertical (scroll-content-space matching CM5 local mode). The inverse method coordsChar was updated to match. (#143)
    • Fork: ~/Repos/codemirror-vim/src/cm_adapter.ts (charCoords and coordsChar split coordinate reference: contentDOM for horizontal, scrollDOM for vertical)

Tests

  • 2 new e2e tests in test/specs/hint-mode.e2e.ts: labels for adjacent elements at the same position should not overlap, labels for elements stacked vertically with small gap should not overlap (#144)
  • 1 new e2e test in test/specs/table-nav-hotkeys.e2e.ts: Ctrl+P opens command palette while in table-nav mode (#146)
  • 4 new e2e tests in test/specs/vim-builtin/visual-mode.e2e.ts: Vc on middle line, Vc before empty line preserves it, Vc on last line, Vjc multi-line change (#145)
  • 4 new Neovim golden test definitions in test/neovim/test-definitions.ts + golden data recorded in test/neovim/golden-data/visual-mode.json (#145)
  • 3 new e2e tests in test/specs/vim-builtin/z-commands.e2e.ts: zz/zt/zb ordering with frontmatter visible, zt vs zz separation with frontmatter, zt places cursor within top 15% of viewport with frontmatter (#143)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: marked table-nav hotkey passthrough as fixed (#146)
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added “Visual-line change operator (Vc)” section, added “Scroll-space charCoords / coordsChar” section)
  • New docs/guides/plugin-integration.md: community plugin integration guide with Better Paste recipes and generic vim.obsidian.run_command pattern (#147)
  • New docs/guides/lua-recipes.md: copy-paste Lua snippets for common workflows (display-line j/k, clipboard sync, leader bindings, auto-save, per-vault config, mobile overrides, picker, and more)
  • docs/guides/index.md: added links to both new guide pages

[0.129.0] - 2026-08-26

Fixed

  • Shared label settings hidden when parent toggle is off — Flash search labels, EasyMotion dimming, EasyMotion label characters, label font size, and scale-to-line-height were only visible when their original parent feature (EasyMotion or Flash f/F/t/T) was enabled, even though they are shared across multiple features (EasyMotion, Flash, Hint mode). Users who disabled Flash f/F/t/T could not configure or even see the Flash search labels toggle. Settings are now visible whenever any feature that uses them is enabled. (#142)
    • Plugin: src/settings.ts (updated visibility gates for flashSearch, easyMotionDimming, easyMotionLabels, labelFontSize, labelMatchFontSize in both declarative and imperative settings paths)
    • Styles: styles.css (added vim-motions-when-easymotion-or-flash and vim-motions-when-easymotion-or-hint-or-flash CSS visibility gate rules)

Documentation

  • CHANGELOG.md
  • docs/configuration/settings.md: updated descriptions for label font size and scale-to-line-height to mention flash

[0.128.0] - 2026-08-25

Fixed

  • EasyMotion line motions (j/k) operate characterwise instead of linewise in operator-pending mode — d<leader><leader>j{label} deleted from cursor column to the target position (characterwise) instead of deleting full lines (linewise). Native Vim j/k have motionArgs: { linewise: true }. Added linewise: true to both easyMotionLine and easyMotionLineBack definitions.
    • Plugin: src/easymotion/register.ts (added motionArgs: { linewise: true, forward: true/false } to line motion entries)
  • EasyMotion forward motions do not set motionArgs.forward — the fork’s clipToLine function (which clips trailing newline+whitespace for multi-line forward operations) never fired for EasyMotion motions. Added forward: true or forward: false to all 17 directional EasyMotion motion definitions.
    • Plugin: src/easymotion/register.ts (added forward flag to all EASYMOTION_DEFS entries)
  • Insert-mode surround macro recording — <C-g>s{char} keys typed during insert mode were not logged to the macro key buffer. The fork’s handleKeyInsertMode now calls logKey when a full insert-mode command is matched, recording the complete key sequence to the macro register.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (handleKeyInsertMode — logKey on match.type == 'full')
  • easyMotionRepeat operator-pending mode — easyMotionRepeat was registered as an action (defineAction) which cannot participate in operator-pending mode. Changed to a motion (defineMotion) with mapCommand binding at <leader><leader>.. The motion inherits motionArgs from the last executed EasyMotion motion, so operators apply with correct linewise/inclusive/forward flags.
    • Plugin: src/easymotion/register.ts (changed from defineAction to defineMotion + mapCommand, added lastMotionArgs storage)
  • :m/:t newline handling at document boundaries — moving or copying lines to position 0 (top of file) or after the last line produced concatenated text without newline separation. Fixed getLineRangeText to include the preceding newline separator when extracting the last line, and createMoveCopyCommand to insert with correct newline placement at boundaries.
    • Plugin: src/workspace/commands.ts (getLineRangeText boundary handling, createMoveCopyCommand insert text normalization)
  • Table cell count prefixes — 3j in both table-nav overlay mode and cell editor mode performed a single cell crossing instead of three. Three changes: (1) table-nav keymap now accumulates digit keys as a count prefix and passes the count to navigate(). (2) navigate() loops count times for directional movement. (3) Cross-cell motion overrides (moveByLines, moveByDisplayLines) now loop repeat times through getCellBelow/getCellAbove, stopping at table boundaries.
    • Plugin: src/vim/table-nav-keymap.ts (digit accumulation, consumeCount(), count parameter on navigation keys)
    • Plugin: src/vim/table-nav-controller.ts (navigate() count parameter, loop)
    • Plugin: src/vim/table-cell-motions.ts (createMoveByLines, createMoveByDisplayLines repeat loop)
  • Cross-cell word motions — w/b/e/W/B/E/ge/gE at cell boundaries did not jump to the adjacent cell. Added a moveByWords override in table-cell-motions.ts that detects when the word motion result is null or stuck at a boundary, and crosses to the next/previous cell via getNextCell.
    • Plugin: src/vim/table-cell-motions.ts (new createMoveByWords override, registered in applyTableCellMotions)
  • Table-nav dot-repeat for structural commands — . in table-nav mode now repeats the last structural command (o, O, dd, dc, J, K, H, L, I, A). Count prefix works (3. repeats 3 times). Cleared on cell edit entry so vim’s native . handles text edits.
    • Plugin: src/vim/table-nav-keymap.ts (lastStructuralAction tracking, . handler with count)
    • Plugin: src/vim/table-nav-controller.ts (clearLastStructuralAction on cell edit entry)
  • Multi-line t column 0 exclusion — forward t{char} targets at column 0 were excluded entirely instead of wrapping to the previous line’s last character. Fixed in both flash (applyTillOffset in char-mode.ts) and EasyMotion (findTillTargets in targets.ts). Targets at column 0 of line 0 (no previous line) are still excluded.
    • Plugin: src/flash/char-mode.ts (applyTillOffset — wrap to previous line)
    • Plugin: src/easymotion/targets.ts (findTillTargets — same fix)
  • EXTRA_DEFS bidirectional motions lack motionArgs — easyMotionBdEndWord, easyMotionBdEndWORD, easyMotionBdTill now have inclusive: true, and easyMotionBdLine has linewise: true. Applied via Object.assign(motionArgs, defArgs) inside the motion function body, bypassing the defineMotion-only limitation.
    • Plugin: src/easymotion/register.ts (EXTRA_DEFS motionArgs + motion function mutation)
  • Tab/Shift+Tab in table-nav — Tab in cell edit mode now exits the cell and returns to table-nav on the next cell. Tab/Shift+Tab also work directly in table-nav mode as cell navigation (equivalent to l/h). Count prefix works (3Tab moves 3 cells forward).
    • Plugin: src/vim/table-nav-keymap.ts (Tab/Shift+Tab case in nav handler)
    • Plugin: src/vim/table-nav-controller.ts (Tab/Shift+Tab Scope handlers in cell edit)

Tests

  • 3 new e2e tests in test/specs/easymotion-comprehensive.e2e.ts: d+easymotion j linewise delete, y+easymotion j linewise yank with register flag, d+easymotion k linewise delete upward
  • 2 new e2e tests in test/specs/easymotion-comprehensive.e2e.ts: d+easyMotionRepeat operator-pending delete, d+easyMotionRepeat after line motion preserves linewise
  • 1 new e2e test in test/specs/surround.e2e.ts: macro register contains <C-g>s keys after recording
  • 6 crash-guard tests in test/specs/vim-builtin/ex-move-copy-normal.e2e.ts strengthened to behavioral assertions (:m0, :m$, :m-2, :1,2m$, :t$, :1,2t$) + 2 new tests (:m3, :t0)
  • 2 new e2e tests in test/specs/table-nav-mode.e2e.ts: 3j moves 3 rows in table-nav, 2l moves 2 columns
  • 2 new e2e tests in test/specs/table-cell-vim-mode.e2e.ts: cross-cell w at end of cell, cross-cell b at start of cell
  • 3 new e2e tests in test/specs/table-nav-mode.e2e.ts: . repeats o, 2. repeats twice, . cleared after cell edit
  • 3 new unit tests in test/unit/flash-targets.test.ts: findTillTargets column 0 wrap, column 0 line 0 exclusion, non-zero column
  • 2 new e2e tests in test/specs/table-nav-mode.e2e.ts: Tab in cell edit returns to nav, Shift+Tab navigates backward
  • 1 new e2e test in test/specs/flash-char-mode.e2e.ts: multi-line t wraps to previous line end at column 0
  • 1 new e2e test in test/specs/easymotion-comprehensive.e2e.ts: y + easyMotionBdLine via mapCommand yanks linewise (EXTRA_DEFS motionArgs)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: marked EasyMotion linewise, forward motionArgs, insert-mode surround macro recording, easyMotionRepeat operator-pending, :m/:t address parsing, table count prefixes, cross-cell word motions, table-nav dot-repeat, multi-line t column 0, EXTRA_DEFS motionArgs, and Tab/Shift+Tab in table-nav as fixed
  • CONTRIBUTING.md: updated easymotion/register.ts and table-cell-motions.ts descriptions, table-nav-keymap.ts description
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (updated insert-mode surround macro recording section)
  • docs/features/tables.md: added count prefix, cross-cell word motions (w/b/e), dot-repeat (.), Tab/Shift+Tab to keybindings table and native mode navigation table
  • docs/features/easymotion.md: added “Repeat last motion” section with <leader><leader>. binding
  • docs/reference/keybindings.md: added “EasyMotion repeat” section with <leader><leader>. binding

[0.127.0] - 2026-08-25

Added

  • Neovim default mapping audit — comprehensive audit of all Neovim default mappings against the plugin implementation, with 20+ mappings added or fixed. Full results documented in NEOVIM_MAPPING_DIFFERENCES.md.
  • gM motion — go to middle character of the text line (by character count). Distinct from gm (middle of screen line).
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new moveToMiddleOfTextLine motion + defaultKeymap entry)
  • g& action — repeat last :s substitution on all lines in the buffer (equivalent to :%s//~/&).
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new repeatLastSubstituteGlobal action + defaultKeymap entry)
  • ]<Space> / [<Space> actions — add N blank lines below/above cursor. Supports count prefix (3]<Space> adds 3 blank lines). Neovim default since 0.10.
    • Plugin: src/workspace/navigation.ts (addBlankLineBelow/addBlankLineAbove actions)
  • <C-W>T action — move current pane to a new tab. Transfers the leaf’s view state to a new tab leaf, then detaches the old leaf.
    • Plugin: src/workspace/navigation.ts (moveToNewTab action)
  • <C-W>^ action — split current window and edit the alternate file. Combines horizontal split + alternate file navigation.
    • Plugin: src/workspace/navigation.ts (splitAlternateFile action)
  • <C-W>n mapping — alias for <C-W>s (new horizontal split). Matches Neovim’s :new equivalent.
    • Plugin: src/workspace/navigation.ts (additional mapCommand call)
  • g<Tab> mapping — go to last accessed tab page. Aliases the existing focusPreviousPane action.
    • Plugin: src/workspace/navigation.ts (additional mapCommand call)
  • ]f / [f mappings — alias for gf (go to file). Matches Neovim bracket file navigation.
    • Plugin: src/workspace/navigation.ts (additional mapCommand calls)
  • v_* / v_# visual search — in visual mode, * and # search for the selected text instead of the word under cursor. Neovim default since 0.10.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new selectedText querySrc + visual-context keymap entries)
  • K keyword lookup — adapted for Obsidian: triggers hover page preview on wikilinks, opens external URLs in browser, falls back to ga character info on plain text.
    • Plugin: src/workspace/navigation.ts (createKeywordLookupAction)
  • g<C-A> / g<C-X> sequential increment — in visual mode, increments/decrements numbers sequentially across selected lines (+1, +2, +3…). Useful for numbered lists: select 0\n0\n0, press g<C-A> → 1\n2\n3.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (sequential path in incrementNumberToken)
    • Fork: ~/Repos/codemirror-vim/src/types.ts (sequential added to ActionArgsPartial)
  • :center / :left / :right ex commands — text alignment. :ce centers lines, :le left-aligns (trims), :ri right-aligns. Optional width argument (default: textwidth or 80).
    • Plugin: src/workspace/commands.ts (createAlignCommand)
  • :retab ex command — replaces all tab characters with spaces using the current tabSize (or an explicit argument).
    • Plugin: src/workspace/commands.ts
  • ]m / [m / ]M / [M method navigation — verified working via the fork’s generic moveToSymbol motion with method mode. Golden test definitions added.

Fixed

  • <C-U> in insert mode deleted to start of line instead of insert-start position — fork now tracks _insertStartPos (the cursor position when insert mode was entered). <C-U> deletes back to that position instead of line start. Falls back to line start when cursor is already at or before the insert-start position. Position is preserved across <C-O> single normal commands, matching Neovim behavior.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new moveToInsertStart motion, _insertStartPos tracking in enterInsertMode/exitInsertMode)
    • Fork: ~/Repos/codemirror-vim/src/types.ts (_insertStartPos added to vimState)
  • :d3 / :m0 style ex commands didn’t work — the ex command parser treated trailing digits as part of the command name (e.g., d3 → unknown command d3). Fixed matchCommand_ to have a fallback pass matching commands by progressively shorter alpha prefixes, and _processCommand to extract the trailing suffix into arguments. Also fixes :g/pattern/m0 subcommand routing.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (matchCommand_ fallback pass, _processCommand arg extraction)
  • :$d cursor position differed from Neovim — after deleting lines with :d, cursor is now positioned at the first non-blank of min(startLine, lastLine()), matching Neovim.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (exCommands.delete cursor positioning)
  • :move / :m and :copy / :co / :t deviations were stale — the plugin already implements these via createMoveCopyCommand in src/workspace/commands.ts, overriding the fork’s cursor-only stub. Deviation entries in deviations.ts should be removed.

Tests

  • 3 new golden test definitions for gM (middle of text line, short line, second line)
  • 1 new golden test definition for g& (repeat substitute on all lines)
  • 2 new golden test definitions for ]m/[m (method start forward/backward)
  • 3 new manual tests for ]<Space>/[<Space> (add blank line below, above, with count)
  • 2 new manual tests for gM (middle of 10-char line, 3-char line)
  • 1 new manual test for g& (repeat substitute on all lines)
  • 2 new manual tests for v_*/v_# (visual search forward/backward)
  • 1 new manual test for <C-U> insert-start (preserves prefix text)
  • Golden data re-recorded for g-commands suite

Documentation

  • CHANGELOG.md
  • NEOVIM_MAPPING_DIFFERENCES.md (new file — full audit document, updated after each round)
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added gM, g&, :d count, <C-U> insert-start, v_*/v_#, ex command name parsing, :$d cursor, g<C-A>/g<C-X>)
  • docs/reference/keybindings.md (added new keybindings)
  • docs/features/ex-commands.md (added alignment and retab commands)
  • docs/features/quality-of-life.md (added new Neovim defaults)

[0.126.0] - 2026-08-24

Added

  • g_ motion — moves to the last non-blank character of the current line (or count-1 lines forward). Inclusive motion matching Neovim’s g_. dg_ deletes through the last non-blank character, preserving trailing whitespace.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new moveToLastNonWhiteSpaceCharacter motion handler and g_ keybinding)
    • Fork: ~/Repos/codemirror-vim/src/types.ts (type definition)

Fixed

  • g^ and g0 display-line motions behaved identically — both called cursorLineBoundaryBackward, which implements Home-key toggle behavior (first non-blank ↔ column 0). g0 incorrectly went to first non-blank instead of column 0, and g^ toggled instead of always going to first non-blank. Now g0 unconditionally moves to column 0 of the visual line, and g^ unconditionally moves to the first non-blank character of the visual line, matching Neovim. (#141)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (new moveToFirstNonBlankOfDisplayLine handler for g^; moveToStartOfDisplayLine now uses goDisplayLineStart)
    • Fork: ~/Repos/codemirror-vim/src/cm_adapter.ts (new goDisplayLineStart exec command using view.moveToLineBoundary)
    • Fork: ~/Repos/codemirror-vim/src/types.ts (type definition for moveToFirstNonBlankOfDisplayLine)

Tests

  • 7 new e2e tests in test/specs/vim-builtin/g-commands.e2e.ts: g0 always column 0, g^ from mid-line, g^ from column 0, g^ idempotent at first non-blank, g_ trailing spaces, g_ no trailing spaces, g_ empty line, 2g_ count, dg_ operator-pending (#141)
  • 7 new Neovim golden test definitions in test/neovim/test-definitions.ts (g0 indented, g^ indented, g^ no whitespace, g^ idempotent, g_ trailing spaces, g_ no trailing, 2g_ count)
  • Golden data recorded in test/neovim/golden-data/g-commands.json

Documentation

  • CHANGELOG.md
  • AGENTS.md: added g0/g^/g_ fork motion descriptions
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md: added g0/g^ fix under “Behavioral fixes (Neovim parity)” and g_ under “New default keymap entries”

[0.125.0] - 2026-08-24

Fixed

  • Vim mode indicator desynchronized when switching tabs — switching between editors in different vim modes (e.g., Insert in Tab A, Normal in Tab B) left the status bar showing the previous editor’s mode. Root cause: VimModeTracker’s active-leaf-change handler attached event listeners to the new adapter but never read the new adapter’s current vim mode — the display only updated when a subsequent vim-mode-change event fired. If the destination editor was already in Normal mode, no event fired and the indicator stayed stale. Fixed by adding syncModeFromAdapter() which reads the vim state’s boolean flags (insertMode, visualMode, visualLine, visualBlock, selectMode, virtualReplace, insertModeReturn) directly from the adapter and updates the display immediately after attaching to the new adapter on leaf change. (#140)
    • Plugin: src/vim/mode-tracker.ts (new syncModeFromAdapter method; called after attachToAdapter in active-leaf-change handler)

Tests

  • 2 new regression tests in test/specs/vim-builtin/mode-indicators.e2e.ts (#140): Insert→Normal tab switch syncs indicator, Normal→Insert tab switch syncs indicator

Documentation

  • CHANGELOG.md

[0.124.0] - 2026-08-24

Changed

  • CI e2e sharding — the e2e workflow now distributes spec files into 36 shards (matching the GitHub Actions concurrent job limit) instead of creating one matrix job per spec file. The discover job distributes specs round-robin into shards; each runner executes 5–6 specs sequentially. This keeps the matrix well under the 256-job GitHub Actions cap as the test suite grows, while maintaining full parallelism across all available runners.
    • CI: .github/workflows/e2e.yml (discover job shards specs into 36 groups, e2e job iterates shard matrix)
  • Cross-platform e2e CI — the e2e workflow now runs the full sharded test suite on macOS (macos-latest, ARM) and Windows (windows-latest) in addition to Linux. No virtual display setup needed on macOS/Windows — GitHub runners provide native GUI sessions. wdio-obsidian-service handles Obsidian download, ChromeDriver version matching, and platform-specific launch per OS. CSC_IDENTITY_AUTO_DISCOVERY=false prevents macOS keychain prompts. 40-minute timeout per job accommodates slower runners. Windows shards retry up to 3 times on EPERM errors from obsidian-launcher’s atomic file rename (Windows NTFS file locking during onPrepare download).
    • CI: .github/workflows/e2e.yml (new e2e-cross-platform job with os: [macos-latest, windows-latest] matrix, EPERM retry logic)

Fixed

  • E2e tests using Key.Ctrl send Cmd on macOS instead of Ctrl — WebDriverIO’s Key.Ctrl is a cross-platform abstraction that maps to Cmd on macOS and Ctrl on Linux/Windows. Vim keybindings like <C-w> and <C-t> require the physical Control key. Changed all vim-related test key dispatches from Key.Ctrl to Key.Control (the actual Control key on all platforms). This fixed 8 <C-w> workspace navigation test failures, 2 which-key overlay test failures, and 1 Oil C-t test failure — all macOS-only.
    • Test: test/specs/global-nav.e2e.ts (Key.Ctrl → Key.Control for <C-w>h/l/q/s/d)
    • Test: test/specs/global-nav-plugin-leaf.e2e.ts (Key.Ctrl → Key.Control for <C-w>h)
    • Test: test/specs/gmap.e2e.ts (Key.Ctrl → Key.Control for <C-w>h and which-key tests)
    • Test: test/specs/gmap-vimrc.e2e.ts (Key.Ctrl → Key.Control for <C-w> bindings)
    • Test: test/specs/oil-poc.e2e.ts (Key.Ctrl → Key.Control for C-t open-in-new-tab)
  • Table nav scroll tests fail on macOS/Windows due to CM6 lazy widget rendering — waitForTableWidget() was called before scrolling the cursor near the table. On platforms with smaller viewports, the table at line 33 of the fixture file was offscreen and CM6 didn’t create .cm-table-widget (lazy rendering). Fixed by scrolling the cursor to line 31 (near the table) before waiting for the widget. Also removed overly strict scrollBefore > 0 assertion that assumed the viewport couldn’t display 31 lines (varies by platform/window size).
    • Test: test/specs/table-nav-scroll.e2e.ts (reordered cursor positioning before waitForTableWidget, removed viewport size assumption)
  • Upgraded @obsidian-typings/obsidian-public-latest from ^6.32.0 to ^6.33.0 — pulls in @obsidian-typings/obsidian-public-1.13.7@1.6.0 which includes full TableEditor, TableCell, TableRow, TableSelectionBounds, CellDirection, CellPosition, CursorPlacement, and TableAlignment type definitions. Replaced local runtime-discovered typings (ObsidianTableEditor, ObsidianTableCell, ObsidianTableRow) with the upstream types. Deleted src/types/table-editor.d.ts (247 lines).
    • Plugin: package.json (dependency version bump)
    • Plugin: src/types/table-editor.d.ts (deleted — replaced by upstream)
    • Plugin: src/vim/native-table-adapter.ts (imports from @obsidian-typings/obsidian-public-latest instead of local file)
    • Plugin: src/vim/table-nav-controller.ts (imports from @obsidian-typings/obsidian-public-latest instead of local file)
    • Plugin: src/vim/table-cell-motions.ts (imports from @obsidian-typings/obsidian-public-latest instead of local file)

Documentation

  • CHANGELOG.md
  • AGENTS.md: added TableEditor/TableCell to key typed APIs list; updated CI container image description to reflect sharding strategy; added cross-platform e2e CI description with EPERM retry
  • CONTRIBUTING.md: removed deleted table-editor.d.ts from src/types/ file tree; updated CI infrastructure paragraph to reflect sharding strategy, cross-platform runners, and Windows EPERM retry

[0.123.0] - 2026-08-22

Fixed

  • Parent editor cursor visible next to table during cell editing — when entering cell edit mode from table-nav (via i, a, c, s, or Enter), the parent editor’s vim cursor layer became visible next to the table widget with an oversized height (spanning the full table widget). Root cause: enterCellEdit() called clearCursorSuppressedForView() and resumeAnimatedCursorForView() immediately, but cell editor focus was deferred by 150ms via setTimeout in finishCellEditEntry(). During the gap, the parent editor still had focus and its BlockCursorPlugin re-rendered the unsuppressed cursor at the table-range position where coordsAtPos() returns the full widget height. Additionally, even after focus transfer, a pending requestMeasure callback from a prior update cycle could redraw the cursor with suppressed=false. Fixed by removing both calls entirely — parent cursor suppression stays active throughout the nav→edit transition. exitCellEditToNav() and exitTable() handle restoring suppression state on exit. (#136)
    • Plugin: src/vim/table-nav-controller.ts (removed clearCursorSuppressedForView + resumeAnimatedCursorForView from enterCellEdit)
  • Visual/Visual Line put command not replacing selection — P, gp, and gP in visual or visual-line mode inserted text at the cursor instead of replacing the selected text. Two root causes: (1) the plugin’s pasteFromRegister() (used by P/gp/gP) had no visual mode handling — it always performed a cursor-relative insert; (2) the fork’s continuePaste() (used by p) read the CM6 selection via getSelectedAreaRange() and cm.getSelection(), which return a collapsed range in visual-line mode due to the cursor-only CM6 selection design (see “Visual-line cursor-only CM6 selection” in KNOWN_LIMITATIONS.md). (#139)
    • Plugin: src/workspace/navigation.ts (new pasteInVisualMode() function; visual mode detection in pasteFromRegister() delegates to it; reads vim.sel for correct range, handles visual-char and visual-line, stores replaced text in unnamed register, calls Vim.exitVisualMode())
    • Plugin: src/types/vim-api.d.ts (added exitVisualMode to VimApi interface)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (continuePaste visual-line branch now derives selectionStart/selectionEnd from vim.sel instead of collapsed CM6 selection; selectedText uses cm.getRange() instead of cm.getSelection(); linewise text preparation preserves trailing newline in visual-line mode since the replacement range spans whole lines, with last-line edge case handling)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (new “Visual-line paste fix” subsection)

Tests

  • 1 new regression test in test/specs/table-cursor-suppression.e2e.ts (#136): parent cursor hidden after entering cell edit via i
  • 7 new tests in test/specs/vim-builtin/normal-yank-put.e2e.ts (#139): v + p, V + p, V + P, v + P, visual paste unnamed register update, v + gp, normal mode return after visual paste
  • 4 new Neovim golden test definitions in test/neovim/test-definitions.ts (#139): v + p, V + p, V + P, v + P — all verified against Neovim
  • Recorded golden data in test/neovim/golden-data/normal-yank-put.json

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: marked parent cursor visible during cell editing as fixed (#136); updated “Table navigation cursor hiding” section with deferred unsuppression in enterCellEdit
  • AGENTS.md: added exitVisualMode to fork API description
  • KNOWN_LIMITATIONS.md: updated yank-ring section (visual-mode gp/gP no longer bypass yank-ring); updated V-LINE cursor-only section (fork’s continuePaste now handles cursor-only selection)
  • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md: new “Visual-line paste fix” subsection

[0.122.0] - 2026-08-22

Fixed

  • Viewport snaps to top of table when entering table-nav — when navigating into a long table from above, the viewport jumped to the top of the table instead of staying in place. During the 80ms entry debounce, Obsidian’s native cell editor opens and scrolls the table into view. The plugin now locks scrollTop via a scroll event listener during the debounce window to prevent the visual snap entirely, then restores the saved position after table-nav activates. (#136)
    • Plugin: src/vim/table-nav-controller.ts (preEntryScrollTop session field, scroll-lock listener in scheduleEntry, focusWithoutScroll, scroll restore in tryEnter + rAF safety net)
  • Table-nav ignores scrolloff setting — table-nav cell scrolling used hardcoded 5px margins instead of the user’s scrolloff setting. With scrolloff=999 (typewriter/centered cursor), the highlighted cell stayed at the viewport edge instead of staying centered. Replaced dispatch-based syncCursorToActiveCell with DOM-level scrollHighlightedCellIntoView (avoids CM6 update cycle side effects) and updated tableNavScrollHandler — both now read the scrolloff margin via getScrolloffMargin() with the same half-viewport clamping as the main editor. Structural commands (o, O, dd, J, K, etc.) now scroll the viewport to follow the highlighted cell after row insertion/deletion. (#136)
    • Plugin: src/vim/table-nav-controller.ts (scrollHighlightedCellIntoView, replaced syncCursorToActiveCell, tableNavScrollHandler uses getScrolloffMargin, refreshAfterDocChange scrolls after structural ops)
    • Plugin: src/vim/scrolloff.ts (new getScrolloffMargin() export)

Tests

  • 2 new tests in test/specs/table-nav-scroll.e2e.ts (#136): entering table-nav should not snap viewport to top of table, scrolloff should keep highlighted cell away from viewport edge
  • Updated test-vault/fixtures/table-nav/LongTable.md to 100 rows with 30 lines of leading content

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: marked viewport snap and scrolloff bugs as fixed

[0.121.0] - 2026-08-22

Fixed

  • Escape does not return to table-nav after Enter cell entry — when entering a cell via Enter in table-nav mode (normal-mode cell entry), pressing Escape did not return to table-nav mode. The cell editor’s vim keydown observer consumed Escape before the Obsidian Scope handler could intercept it. Additionally, the cell editor’s vim state had mode: null (pre-initialization) which caused isVimIdle() to return false. Fixed by installing a capture-phase keydown listener on document during cell-edit mode that intercepts Escape when vim is idle, and introducing isCellVimIdle() which treats null/undefined mode as idle (equivalent to normal mode during cell editor initialization). (#136)
    • Plugin: src/vim/table-nav-controller.ts (isCellVimIdle, installCellEscapeCapture, removeCellEscapeCapture, cellEscapeCleanup session field)
  • Community plugin replaceSelection fails in visual-line mode — community plugins like Note Refactor that call editor.replaceSelection() after async operations (file creation, link generation) failed to replace the selected text in visual-line mode. The existing withExpandedSelection wrapper expanded the CM6 selection synchronously and restored cursor-only in its finally block, but async command callbacks executed replaceSelection() after the selection was already restored. Additionally, editor.replaceSelection() was not patched in the VisualLineSomethingSelectedPatch ViewPlugin (only somethingSelected() and getSelection() were). Fixed by patching editor.replaceSelection() to compute the linewise range from vim’s selection state and dispatch a CM6 replacement transaction directly, with trailing newline handling for mid-document lines. The patch exits visual-line mode via Vim.handleKey(cm, '<Esc>') after replacement since the selection has been consumed. (#138)
    • Plugin: src/vim/visual-line-command-fix.ts (replaceSelection patch in VisualLineSomethingSelectedPatch, cleanup in destroy())

Tests

  • 4 new tests in test/specs/table-cell-vim-mode.e2e.ts (#136): Escape after Enter returns to nav mode, single Escape from insert mode does NOT exit to nav (regression guard), j after Enter cell edit navigates to next row, cursor height in cell normal mode guard
  • New helper getCellVimMode() in test/specs/table-cell-vim-mode.e2e.ts
  • 5 new spike tests in test/specs/spikes/spike-issue138-vline-async-replaceSelection.e2e.ts (#138): V-LINE getSelection() baseline, sync replaceSelection via executeCommandById, async replaceSelection via fake command (Note Refactor pattern), direct async replaceSelection without executeCommand, sync direct replaceSelection without wrapper

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: marked Escape-from-cell-edit bug as fixed; updated V-LINE passthrough section with replaceSelection patch details and updated trade-off/test coverage

[0.120.1] - 2026-08-21

Changed

  • Removed !important from table-nav CSS — replaced overflow: visible !important on .vim-motions-table-nav-mode .cm-table-widget with higher-specificity selectors (.cm-editor .vim-motions-table-nav-mode.cm-table-widget). The added .cm-editor ancestor provides enough specificity to override Obsidian’s built-in overflow: hidden without !important.
    • Styles: styles.css (increased selector specificity, removed !important)

Documentation

  • CHANGELOG.md
  • AGENTS.md: added “Never use !important in CSS” coding convention
  • CONTRIBUTING.md: added !important avoidance guideline to code style section

[0.120.0] - 2026-08-21

Added

  • Vim/Neovim built-in gap coverage — systematic effort to close ~50 gaps in vim/neovim built-in command coverage across 8 implementation batches.
  • Fork: @: repeat last ex command — new repeatLastExCommand action replays the most recent ex command. Added to defaultKeymap.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (repeatLastExCommand action + defaultKeymap entry)
  • Fork: & repeat last :s on current line — new repeatLastSubstitute action re-executes the last :s substitution. Added to defaultKeymap.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (repeatLastSubstitute action + defaultKeymap entry)
  • Fork: ZZ write+quit and ZQ quit without saving — mapped in defaultKeymap via exArgs: { input: 'wq' } and exArgs: { input: 'q' } respectively.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (defaultKeymap entries)
  • Fork: Insert <C-a> re-insert previously inserted text — new reinsertPreviousInsert action replays the last insert-mode text at the cursor.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (reinsertPreviousInsert action)
  • Fork: Insert <C-e> copy character from line below — new copySameColumnBelow action copies the character at the same column from the line below.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (copySameColumnBelow action)
  • Fork: Insert <C-y> copy character from line above — new copySameColumnAbove action copies the character at the same column from the line above.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (copySameColumnAbove action)
  • <C-w>w / <C-w>W cycle panes — cycle focus to the next or previous pane in the workspace.
    • Plugin: src/workspace/navigation.ts
  • <C-w>p focus previous pane — jump to the previously accessed pane using leaf ID tracking.
    • Plugin: src/workspace/navigation.ts
    • Plugin: src/main.ts (previousLeafId tracking extracted from Harpoon-gated handler into unconditional handler)
  • gm go to middle of screen line — positions cursor at the horizontal midpoint of the visible editor area.
    • Plugin: src/workspace/navigation.ts
  • go go to character offset — jumps to the Nth byte offset in the buffer (with count prefix).
    • Plugin: src/workspace/navigation.ts
  • g8 show UTF-8 byte sequence — displays the UTF-8 hex byte values for the character under the cursor.
    • Plugin: src/workspace/navigation.ts
  • gF go to file with line number — opens the file path under the cursor, optionally jumping to a line number suffix (e.g., file.md:42).
    • Plugin: src/workspace/navigation.ts
  • <C-g> show file info — displays filename, line count, cursor position, and percentage through file in a notice.
    • Plugin: src/workspace/navigation.ts
  • <C-^> / <C-6> alternate file switching — switch between the current and alternate (previously edited) file, matching Neovim’s <C-^> behavior.
    • Plugin: src/main.ts (alternateFilePath / lastMarkdownFilePath fields + <C-^> / <C-6> mapping)
  • <C-]> follow link under cursor — alias for gd (go to definition).
    • Plugin: src/main.ts
  • <C-t> pop from link follow — alias for jump list backward navigation.
    • Plugin: src/main.ts
  • zs / ze / zH / zL horizontal scroll commands — scroll the viewport horizontally without moving the cursor.
    • Plugin: src/workspace/navigation.ts
  • Ex commands for :move, :copy, and :normal — add :m/:move line moves, :t/:copy/:co line copies, and :normal/:normal! key dispatch from the ex line.
    • Plugin: src/workspace/commands.ts (line transfer helpers + :normal key feeding)
  • :tabmove no-op registration — registered as a no-op with a notice (Obsidian has no tab reorder API).
    • Plugin: src/workspace/commands.ts
  • No-op crash guards — 21 commands registered as no-ops to prevent crashes on unrecognized keys: window commands (<C-w>=, <C-w>_, <C-w>|, <C-w>r, <C-w>R, <C-w>x), spelling (]s, [s, z=, zg, zw), normal U, <C-l>, g<C-a>, g<C-x>, insert <C-r>=, <C-k>, <C-v>, <C-x> family.
    • Plugin: src/workspace/navigation.ts

Fixed

  • Table cell cursor bounce-back on macOS (continued) — the MessageChannel-based scheduleCrossing() from 0.119.0 still raced with Obsidian’s table widget focus handlers on macOS Electron. Replaced with requestAnimationFrame, which defers the cross-cell focus change until after the full event dispatch cycle and paint frame complete — guaranteeing Obsidian’s table widget keydown handlers have finished before the plugin changes cell focus. (#136)
    • Plugin: src/vim/table-cell-motions.ts (scheduleCrossing now uses requestAnimationFrame instead of MessageChannel)
  • Table-nav viewport does not follow cursor in long tables — when navigating down through a table taller than the viewport with enableTableNav=true, the highlighted cell went off-screen because navigate() only updated the CSS highlight class without scrolling. CM6 treats the native table widget as an opaque block decoration and cannot scroll to positions within it. Fixed by registering an EditorView.scrollHandler facet that intercepts scroll requests during table-nav mode, reads the highlighted cell’s DOM bounding rect, and adjusts scrollDOM.scrollTop directly — the CM6-sanctioned mechanism for custom scroll behavior that is not overridden by viewport reconciliation. Added overflow: visible CSS override on the table widget during nav mode to prevent the widget’s overflow: auto hidden from blocking scrollIntoView propagation. (#136)
    • Plugin: src/vim/table-nav-controller.ts (syncCursorToActiveCell, tableNavScrollHandler extension)
    • Styles: styles.css (overflow: visible on .vim-motions-table-nav-mode.cm-table-widget)

Tests

  • 1 new e2e spec file: test/specs/table-nav-scroll.e2e.ts (viewport scrolling in long tables with constrained scroller height, #136)
  • 1 new fixture file: test-vault/fixtures/table-nav/LongTable.md (30-row table for scroll testing)
  • Updated test/neovim-command-index.yaml — 50 new entries (287→337 total, 313 tested + 21 skip + 3 pending).
  • 5 new e2e spec files:
    • test/specs/vim-builtin/new-commands.e2e.ts (11 tests: @:, &, ZZ, ZQ, insert <C-a>/<C-e>/<C-y>)
    • test/specs/vim-builtin/link-nav-window-cycle.e2e.ts (13 tests: <C-^>, <C-]>, <C-t>, <C-w>w/W/p)
    • test/specs/vim-builtin/ex-move-copy-normal.e2e.ts (19 tests: :m, :t, :normal)
    • test/specs/vim-builtin/minor-motions-scroll.e2e.ts (18 tests: gm, go, g8, gF, <C-g>, zs/ze/zH/zL)
    • test/specs/vim-builtin/noop-commands.e2e.ts (10 tests: all no-op crash guards)

Documentation

  • CHANGELOG.md
  • AGENTS.md: updated fork description with new actions, updated test file organization
  • KNOWN_LIMITATIONS.md: added :m/:t address parsing limitation
  • docs/reference/keybindings.md: added new normal-mode, insert-mode, window, g-prefix, z-prefix, and ex command entries
  • docs/features/ex-commands.md: added :m/:t/:normal editing commands section
  • docs/features/workspace-navigation.md: added pane cycling, alternate file, link navigation, and document info sections
  • CONTRIBUTING.md: updated table-cell-motions.ts and table-nav-controller.ts descriptions
  • KNOWN_LIMITATIONS.md: documented table-nav viewport scrolling fix

[0.119.0] - 2026-08-20

Fixed

  • Table cell cursor bounce-back on macOS — scheduleCrossing() in the cross-cell motion overrides used setTimeout(0) to defer cell focus changes after vim’s motion return. On macOS Electron, setTimeout(0) is subject to timer clamping (1–4ms minimum delay), during which Obsidian’s native table widget event handlers re-assert focus on the original cell — producing cursor bounce-back. Users with j→gj / k→gk remappings were especially affected because the remapping adds an extra key processing step. Fixed by replacing setTimeout(0) with MessageChannel port messaging, which dispatches a macrotask that fires before timers in the browser event loop. Token-based deduplication ensures rapid key repeats coalesce correctly. (#136)
    • Plugin: src/vim/table-cell-motions.ts (scheduleCrossing now uses MessageChannel instead of setTimeout(0))
  • Cross-cell motions broken when enableTableNav=false — the 0.118.0 fix for #136 incorrectly gated applyTableCellMotions() on enableTableNav, which removed the motion overrides entirely when table nav was disabled. This broke j/k/h/l cross-cell navigation in native table cell editors. Reverted the gate — cross-cell motions are independent of enableTableNav, as originally designed. (#136)
    • Plugin: src/main.ts (removed enableTableNav check from both applyTableCellMotions registration sites)

Tests

  • Added native-mode cross-cell tests in test/specs/table-nav-disabled.e2e.ts (#136): j cross row, k cross row, l cross cell, j exit table, no-overlay assertion, cell editor opens — using WebDriver $().click() for reliable cell entry and waitUntil for async assertions
  • Added raw-mode tests with j→gj/k→gk remappings (j down, k up)
  • Updated test/specs/table-cell-vim-mode.e2e.ts: cross-cell j/l tests use waitUntil for deterministic assertions
  • Updated test/specs/table-cursor-suppression.e2e.ts: #127 tests use G/gg jumps instead of j/k table traversal; #135 tests use WebDriver cell click + waitUntil for table-nav entry

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: reverted cross-cell motions documentation to reflect independence from enableTableNav
  • CONTRIBUTING.md: updated table-cell-motions.ts description
  • docs/features/tables.md: reverted table modes matrix and cell editor behavior description
  • docs/configuration/settings.md: reverted Table navigation setting description

[0.118.0] - 2026-08-20

Changed

  • Cross-cell motions now respect enableTableNav — h/j/k/l crossing cell boundaries in native table mode was previously always active regardless of enableTableNav. Cross-cell motions now only activate when enableTableNav is true. When disabled, Obsidian’s native table cell editor handles cell boundary navigation directly.

Fixed

  • Table movement broken when enableTableNav=false (macOS) — applyTableCellMotions() overrode moveByLines, moveByCharacters, and moveByDisplayLines globally whenever tableWidgetMode was native, regardless of enableTableNav. When table nav was disabled, these overrides still intercepted j/k (and gj/gk) inside native table cells, calling scheduleCrossing() with setTimeout(0) which raced with Obsidian’s native cell focus management on macOS — producing cursor bounce-back. Users with j→gj / k→gk remappings (common vimrc/Lua pattern) were especially affected because the remapping routes through moveByDisplayLines. Fixed by gating applyTableCellMotions() on enableTableNav — when the user disables table nav, the motion overrides are not installed. Obsidian’s native table cell editor handles cross-cell navigation on its own. (#136)
    • Plugin: src/main.ts (added enableTableNav check to both applyTableCellMotions registration sites)

Tests

  • Updated test/specs/table-nav-disabled.e2e.ts (#136): replaced flaky native table cross-cell tests with enableTableNav=false no-overlay assertion; added 2 raw mode tests with j→gj/k→gk remappings (j down, k up)
  • Updated test/specs/table-cell-vim-mode.e2e.ts: enableTableNav=false cross-cell tests now assert cursor stays within cell (no cross-cell override)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: updated cross-cell motions documentation to reflect enableTableNav gating; updated table modes matrix; updated tablewidget mode description
  • CONTRIBUTING.md: updated table-cell-motions.ts description to reflect enableTableNav gating
  • docs/features/tables.md: updated table modes matrix and cell editor behavior description for disabled table-nav
  • docs/configuration/settings.md: updated Table navigation setting description

[0.117.0] - 2026-08-20

Fixed

  • Note Composer “Extract current selection” does nothing in V-LINE mode — in visual-line mode, the codemirror-vim fork sets a cursor-only CM6 selection (to prevent Live Preview from uncollapsing hidden markup). This caused editor.somethingSelected() to return false, so commands that check for a selection silently failed. The existing executeCommand wrapper expanded the selection before command execution, but Obsidian’s command palette invokes checkCallback() directly on the command object, bypassing executeCommand entirely. Fixed by wrapping every command’s checkCallback to expand the visual-line selection before the callback runs, and wrapping addCommand to cover commands registered after plugin load. (#137)
    • Plugin: src/vim/visual-line-command-fix.ts (wrap checkCallback on all commands + addCommand hook; extract shared withExpandedSelection helper)
  • Cursor flashing at previous cell during table navigation — when table navigation was enabled, the vim cursor layer remained visible at the previous cell position after entering the table and after navigating between cells with h/j/k/l. Three root causes: (1) tryEnter() never dispatched the enterTableNav state effect, so isTableNavActive() always returned false — the mainEditorTableCursorGuard continued running during table-nav and could clear cursor suppression. (2) The vim cursor layer (.cm-vimCursorLayer) on the main editor was not proactively cleared during navigation, allowing stale cursor elements to remain visible. (3) cellEditorCursorGuard.destroy() unconditionally called clearCursorSuppressedForView() on the parent editor, undoing the controller’s suppression even while table-nav was active. Additionally, cell editors inside the table widget are destroyed and recreated during entry, each creating a fresh BlockCursorPlugin with a visible cursor layer — the controller now suppresses these on every ViewUpdate via suppressWidgetCursorLayers(). (#135)
    • Plugin: src/vim/table-nav-controller.ts (dispatch enterTableNav effect, clearVimCursorLayer() helper, suppressWidgetCursorLayers() on every update + entry + rAF safety net)
    • Plugin: src/vim/table-cell-cursor-guard.ts (cellEditorCursorGuard.destroy() guards on isTableNavActive())

Tests

  • 3 regression tests in test/specs/visual-line-command.e2e.ts (#137): note-composer:split-file checkCallback returns true in V-LINE, editor.somethingSelected() returns true in V-LINE, executeCommandById affects all selected lines in V-LINE
  • 3 regression tests in test/specs/table-cursor-suppression.e2e.ts (#135): main editor cursor suppression during navigation, rapid multi-directional navigation, no visible cursor anywhere on initial entry
  • 3 regression tests in test/specs/table-nav-disabled.e2e.ts (#136): cursor movement through raw tables with enableTableNav=false — j down, k up, j exits table

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: updated visual-line command passthrough description to reflect checkCallback wrapping for command palette path (#137); updated table navigation cursor hiding entry; added #136 cross-reference to #132 cursor disappearing fix
  • CONTRIBUTING.md: updated table-nav-controller.ts and table-cell-cursor-guard.ts descriptions

[0.116.0] - 2026-08-18

Changed

  • E2E CI: custom Docker runner image — the e2e workflow now runs each spec inside a custom container image (ghcr.io/<repo>/e2e-runner:latest) with Xvfb, herbstluftwm, Node.js 24, and Electron system dependencies pre-installed. The entrypoint starts the virtual display with readiness polling (polls xdpyinfo and herbstclient every 200ms, fails after 6s) instead of the previous sleep 1 race condition. Eliminates per-runner apt-get update + apt-get install (~30s per shard × 79 shards). The image is built and pushed to GHCR by .github/workflows/docker-e2e-runner.yml on Dockerfile changes or manual dispatch.
    • New: .github/docker/e2e-runner/Dockerfile (Ubuntu 24.04 base, Xvfb, herbstluftwm, dzen2, x11-xserver-utils, Electron deps, Node.js 24)
    • New: .github/docker/e2e-runner/entrypoint.sh (Xvfb + herbstluftwm readiness polling, exec "$@" handoff)
    • New: .github/workflows/docker-e2e-runner.yml (build + push to GHCR on Dockerfile changes)
    • Changed: .github/workflows/e2e.yml (uses container: with the custom image, removed Setup virtual display step and actions/setup-node, added npm cache via actions/cache)

Documentation

  • CHANGELOG.md
  • AGENTS.md: Added CI container image documentation to Automated testing section
  • CONTRIBUTING.md: Added CI infrastructure note to Running E2E tests section

[0.115.2] - 2026-08-18

[0.115.1] - 2026-08-18

Fixed

  • CI issue with obsidian-workflows.

[0.115.0] - 2026-08-18

Added

  • Fold navigation motions (zj, zk, [z, ]z) — zj moves to the start of the next foldable region (skipping child folds within the current heading’s range, matching Neovim’s sibling-fold semantics). zk moves to the end of the previous foldable region. [z/]z navigate to the start/end of the enclosing foldable region. All four support counts (3zj), operator-pending mode (dzj), and record to the jump list. Ex command aliases: :foldnext, :foldprev, :foldstart, :foldend.
    • Plugin: src/fold/motions.ts (NEW — findNextFoldable, findPrevFoldable, findEnclosingFoldable, foldedRangesWithin, foldableRegionsWithin, foldNext, foldPrev, foldStart, foldEnd)
    • Plugin: src/fold/commands.ts (registered motions + ex commands)
  • Fold state commands (zn, zN, zi, zv, zF, zx, zX) — zn disables folding (opens all folds, prevents new folds). zN re-enables folding. zi toggles. zv opens folds to reveal cursor line. zF creates a fold for [count] lines. zx/zX reapply fold level (preserving manual folds). Configure via set foldenable / set nofoldenable in vimrc or vim.opt.foldenable in Lua.
    • Plugin: src/fold/fold-enable.ts (NEW — foldEnableField StateField, isFoldingEnabled guard, zn/zN/zi actions)
    • Plugin: src/fold/commands.ts (zv, zF actions)
    • Plugin: src/fold/fold-level.ts (zx, zX actions)
    • Plugin: src/vim/options.ts (foldenable vim option)
  • Heading fold provider with trailing blank line trimming — custom foldService provider for Markdown headings that trims trailing blank lines from fold ranges, matching Neovim’s treesitter fold boundaries. Overrides Obsidian’s built-in foldNodeProp-based heading folds.
    • Plugin: src/fold/provider.ts (headingFold function added to markdownFoldProvider)

Changed

  • Recursive fold operations (zO, zC, zA, zD) — uppercase fold commands now operate recursively on all folds within the cursor’s foldable region using range containment. Previously mapped identically to lowercase variants.
    • Plugin: src/workspace/navigation.ts (foldOpenRecursiveAction, foldCloseRecursiveAction, foldToggleRecursiveAction)
    • Plugin: src/fold/commands.ts (foldDeleteRecursiveAction)
  • Fold-enable guards — fold-creating/closing actions (zc, za, zM, zm, zf, zF) respect the foldenable state. Unfold operations (zo, zR, zr, zv) always work regardless of foldenable.
    • Plugin: src/fold/commands.ts, src/workspace/navigation.ts, src/fold/fold-level.ts

Tests

  • 12 Neovim golden test definitions for fold motions in test/neovim/test-definitions.ts (fold-motions suite with treesitter fold setup)
  • 16 E2E tests in test/specs/vim-builtin/fold-motions.e2e.ts: 12 golden comparison tests + 4 plugin-specific tests (operator-pending dzj/dzk, no-op without foldable regions)
  • 3 known deviations registered in test/neovim/deviations.ts: zk count (treesitter fold hierarchy), [z/]z (fold body vs heading boundary)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated zO/zC/zA and zn/zN rows as fixed; updated golden test coverage table with new fold commands
  • CONTRIBUTING.md: Updated src/fold/ structure with new files
  • README.md: Updated Folding feature description
  • docs/reference/keybindings.md: Added Fold commands section
  • docs/features/workspace-navigation.md: Added Fold motions, Fold state, Recursive fold sections
  • docs/configuration/settings.md: Added foldenable option

[0.114.0] - 2026-08-18

Fixed

  • Vim engine settings fields lock after typing on iPad (and other mobile) — the “Insert mode escape” field (and other vim engine settings like timeoutlen, operator shadow timeout) became greyed out and unresponsive after typing a single character on iPad with Magic Keyboard. Two root causes: (1) vim.setOption() in the onChange handler fired the option’s notify callback, which called onSettingOverride and added the key to vimrcOverrides — making isOverridden() return true and refreshDomState() disable the field. The clearSettingOverride() call ran before vim.setOption(), so the override was re-added immediately after clearing. Fixed by moving clearSettingOverride() to after vim.setOption() in both setControlValue (declarative/post-1.13 path) and all 9 imperative onChange handlers for VIM_OPTION_KEYS settings. (2) The initial settings sync in reloadFeatures() called vim.setOption() for non-default values (clipboard, textwidth, insertmodeescape, etc.) after registerVimOptions() had already set registered = true, causing notify to fire and mark these settings as overridden. Fixed by making registerVimOptions() return an activation function — registered is only set to true when the caller invokes it after the initial sync completes. (#125)
    • Plugin: src/settings.ts (setControlValue — clearSettingOverride moved after vim.setOption + refreshDomState; 9 imperative onChange handlers — same reorder)
    • Plugin: src/vim/options.ts (registerVimOptions — returns () => void activation function instead of setting registered = true internally)
    • Plugin: src/main.ts (captures activation function, calls it after initial settings sync block)

[0.113.0] - 2026-08-17

Fixed

  • Cursor disappears when entering a table in source mode or raw mode — when the cursor entered a table range in source mode or with tableWidgetMode='raw', the vim cursor became invisible while editing still worked. Root cause: mainEditorTableCursorGuard suppressed the vim cursor whenever the cursor was in a text range matching table syntax (findTableRanges()), without checking whether a native table widget was actually visible. In source mode there are no .cm-table-widget elements; in raw mode they exist but are hidden via display: none. In both cases, the cursor was suppressed with no alternative cursor shown. Fixed by adding a hasVisibleTableWidget() check that requires at least one .cm-table-widget element with a non-null offsetParent before suppressing. The check also short-circuits the findTableRanges() document scan when no visible widgets exist. (#132)
    • Plugin: src/vim/table-cell-cursor-guard.ts (hasVisibleTableWidget() function; mainEditorTableCursorGuard.update() — gates cursor suppression on visible widget presence)
  • Cell-edit h/j/k/l unconditionally exits to table-nav in normal mode — when editing a table cell with table-nav enabled, pressing h/j/k/l in normal mode (after Escape from insert mode) immediately exited to table-nav and navigated to the adjacent cell, even when the cursor had room to move within the cell. Root cause: the cellEditScope hjkl handlers only checked isVimIdle() — if idle, they unconditionally called exitCellEditToNav() + navigate() without checking whether the cursor was at a cell boundary. Fixed by adding a cursorAtCellBoundary() method that checks cursor position against cell content bounds: h exits only at ch <= 0, l at ch >= lineLen - 1, j at last line, k at first line. When the cursor is not at the boundary, the handler returns undefined to let vim process the key as normal in-cell movement. (#131)
    • Plugin: src/vim/table-nav-controller.ts (cursorAtCellBoundary method; installCellEditScope hjkl handlers — boundary check before exitCellEditToNav)

Tests

  • 5 regression tests for cursor visibility in source mode and raw table mode in test/specs/table-cursor-source-mode.e2e.ts (issue #132): 3 source mode tests (cursor layer state unchanged on table line, after traversal, on data row) + 2 raw mode tests (widget hidden, cursor layer stable during repeated traversal)
  • 5 regression tests for cell-edit hjkl boundary behavior in test/specs/table-nav-mode.e2e.ts (issue #131): l mid-cell stays in cell, h mid-cell stays in cell, l at end exits to nav, h at start exits to nav, insert→Escape→l stays in cell
  • Systematic e2e test audit — audited all 126 non-spike e2e test files across 8 parallel analysis passes. Fixed ~60 individual test assertions across 40 files: replaced vacuous toContain(already-present-substring) assertions with exact buffer equality, added register preservation checks, converted conditional early-returns to mandatory assertions or visible this.skip() calls, removed 2 exact duplicate tests, and fixed 10 test name/behavior mismatches.
  • Test infrastructure hardening — 6 structural improvements to the test infrastructure:
    • Global afterTest hook in wdio.conf.mts: cleans up overlays (hint, easymotion, which-key, ex-suggest), picker modals (via Escape dispatch), generic modals (via close-button click), notices, and Vim state (double <Esc>) between every test. Includes verification pass that force-removes surviving elements.
    • Strict helpers in test/helpers.ts: setupEditor, sendVimEscape, getEditorValue, getCursorPos, getCursorLine, getSelection, focusEditor, ensureLivePreview, ensureSourceMode now throw with context (e.g., "setupEditor: no MarkdownView (active leaf type: graph)") instead of silently returning defaults.
    • waitUntil-based synchronization: setupEditor waits for content match, loadSingleFileWorkspace waits for MarkdownView, ensureLivePreview/ensureSourceMode wait for mode change — replacing fixed browser.pause() delays.
    • Settings mutation reliability: setPluginSetting now awaits saveSettings(). New setPluginSettingAndReload helper sets + saves + calls reloadFeatures() + waits for settle.
    • Golden enforcement: testWithNeovim now throws "Missing golden case" when no golden data exists (unless the test is a known deviation), preventing silent passes.
    • Hint-mode link navigation: findHintLabelForLink updated to use getBoundingClientRect() with CSS var fallback, wider CM6 selectors (.cm-link, .cm-url, [data-href]), and active-leaf scoping (.workspace-leaf.mod-active .cm-editor).
  • Hint-mode-links fully unblocked — 15 previously-skipped hint-mode link navigation tests now pass. Root causes fixed: (1) vault fixture files created under test-vault/fixtures/hint-mode/ to trigger Obsidian’s full rendering pipeline (CM6 link decorations, metadata cache), (2) before() hook warms link cache by opening all fixtures, (3) findHintLabelForLink scoped to active leaf’s .cm-editor.
  • New unit tests — 6 new unit test files (96 tests total):
    • oil-parser.test.ts (15 tests): buffer line parsing, id/type/name extraction, .md auto-append, Windows line endings, names with spaces
    • oil-diff.test.ts (11 tests): rename/delete/create detection, foreign ids, move resolution across multi-buffer diffs
    • vimrc-parser.test.ts (35 tests): all 13 command types, noremap detection, context inference, icon/color extraction, comments, multi-line parsing
    • flash-labeler.test.ts (10 tests): label assignment, distance sorting, 2-char labels, reuse, skipChars
    • fold-persistence.test.ts (7 tests): load/save round-trip, removePath, renamePath, TTL eviction, max entries eviction
    • pair-util.test.ts (12 tests): symmetric/asymmetric delimiters, nesting, multiline, scan limits, empty pairs
  • New e2e tests — 3 new e2e test files (11 tests total):
    • insert-escape.e2e.ts (6 tests): jk/jj escape sequences, character cleanup, timeout behavior, non-matching sequences, empty config
    • scrolloff-cursorline-smoke.e2e.ts (4 tests): scrolloff setting persistence + cursor positioning, cursorline enable/disable cycle
    • context-actions-smoke.e2e.ts (1 test): :contextactions command opens a modal

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added cursor disappears in source/raw mode as fixed (#132); updated cross-cell navigation description — h/j/k/l now move within cell when cursor is not at boundary (#131)
  • CONTRIBUTING.md: Updated table-cell-cursor-guard.ts description with hasVisibleTableWidget() check; updated table-nav-controller.ts description with cursorAtCellBoundary boundary check
  • docs/features/tables.md: Added note about cursor visibility fix in source/raw mode; updated cell-edit behavior description — h/j/k/l move within cell before boundary exit
  • AGENTS.md: Updated test helpers description (strict behavior, waitUntil synchronization, setPluginSettingAndReload); added afterTest hook and vault fixtures documentation; added golden enforcement description; updated unit test list
  • CONTRIBUTING.md: Updated test infrastructure tree (vault fixtures, snippets subdirs, test-wrapper.ts golden enforcement); updated shared helper descriptions (strict behavior, waitUntil); added vault fixture and afterTest cleanup guidance to key testing rules

[0.112.0] - 2026-08-16

Changed

  • Internal API type safety — obsidian-typings migration (round 2) — eliminated 23 additional as unknown as casts across 16 source files by leveraging @obsidian-typings/obsidian-public-latest v6.32.0 typed APIs. Total as unknown as count reduced from 90 → 67. The remaining 67 casts are inherent to plugin architecture (dynamic settings indexing, codemirror-vim fork adapter access, external plugin window globals, fengari Lua bridge, minAppVersion compatibility guards).
    • src/util/commands.ts: app.commands.executeCommandById() and app.commands.commands accessed directly via typed Commands interface; custom ObsidianCommand narrowed to Pick<Command, 'id' | 'name'>
    • src/util/leaf.ts: leaf.id and leaf.pinned used directly (required properties via WorkspaceItem/WorkspaceLeaf augmentation); getViewFilePath()/getViewFileBasename() use instanceof FileView guard instead of as unknown as { file? } cast
    • src/util/vault.ts: ConfigItem imported from @obsidian-typings/obsidian-public-latest replacing custom VaultConfigKey type inference
    • src/workspace/global-defaults.ts: mdView.getMode() called directly (typed as MarkdownViewModeType)
    • src/editors/embeddable-editor.ts: app.embedRegistry accessed directly; editorApp.scope accessed directly (official API); workspace.activeEditor assignment typed via MarkdownFileInfo
    • src/oil/keybindings.ts, src/oil/manager.ts: app.internalPlugins.getEnabledPluginById('file-explorer') returns typed FileExplorerPluginInstance with revealInFolder(item: TAbstractFile)
    • src/oil/oil-view.ts: this.leaf.updateHeader() called directly (typed on WorkspaceLeaf augmentation)
    • src/oil/manager.ts: app.openWithDefaultApp(path) called directly (typed on App augmentation)
    • src/vim/native-table-adapter.ts: EditMode type extends MarkdownEditView instead of Record<string, unknown>; view.editMode accessed directly; isInLivePreview() uses view.getMode() + editMode.sourceMode instead of getState() cast; getEditModeForView() uses instanceof MarkdownView guard
    • src/vim/table-cell-cursor-guard.ts: mdView.editor.cm accessed directly (typed as EditorView via Editor augmentation)
    • src/ui/global-ex-command.ts: this.inputEl accessed directly (official SuggestModal.inputEl)
    • src/lua/loader.ts: view.getViewType() called directly (official View API) — 3 instances
    • src/settings.ts: this.display() and this.refreshDomState() — reverted, casts retained to bypass obsidianmd/no-unsupported-api and @typescript-eslint/no-deprecated lint rules (plugin minAppVersion is 1.7.2; these APIs require/deprecate at 1.13.0)
    • src/picker/sources/tasks.ts: app.plugins.plugins['obsidian-tasks-plugin'] accessed directly via typed Plugins interface

Fixed

  • Cursor shape dropdowns always disabled in Settings UI — the 5 cursor shape dropdowns (Normal, Insert, Visual, Replace, Operator-pending) on the Appearance page were permanently disabled even when Obsidian’s built-in Vim mode was off. Root cause: Obsidian’s addSettingTab() immediately calls getSettingDefinitions() and caches the result for rendering and search indexing. In onload(), addSettingTab() ran before createBundledVimExtension(), so the disabled callbacks closed over forkActive = false (a const captured at the top of getSettingDefinitions()). The callbacks always returned true (disabled) regardless of the actual fork activation state. Fixed by replacing the captured forkActive const in all 5 disabled callbacks with a direct isBundledVimActive() call, so Obsidian’s refreshDomState() always evaluates the current state. Additionally, this.declarativeSettingTab.update() is now called after createBundledVimExtension() to refresh the cached getSettingDefinitions() result — this updates the static description text which cannot use a callback. (#128)
    • Plugin: src/settings.ts (5 cursor shape disabled callbacks — !forkActive → !isBundledVimActive())
    • Plugin: src/main.ts (store setting tab reference as declarativeSettingTab; call declarativeSettingTab.update() after createBundledVimExtension())
  • Animated cursor suppression not synced on reloadFeatures() — setCursorSuppressed(this.settings.animatedCursor) was only called during initial plugin load (onload()), not during reloadFeatures(). Any runtime setting change that called reloadFeatures() (settings UI toggle, vimrc set smoothcursor, Lua vim.opt.smoothcursor) did not update the global cursor suppression flag in the codemirror-vim fork. The animated cursor canvas would draw but the native CM6 block cursor was not suppressed, causing both cursors to render simultaneously. Also fixed the born-broken table-cursor-suppression.e2e.ts test (5 of 6 failures since commit 99e5fea) whose enableAnimatedCursor() helper set the setting and called reloadFeatures() but never triggered the global suppression. (#127)
    • Plugin: src/main.ts (reloadFeatures() — added setCursorSuppressed(this.settings.animatedCursor) call)
  • Doubled cursors when animated cursor is disabled — when animated cursor was disabled, the native CM6 text caret (thin blinking bar) appeared alongside the fork’s vim cursor (block/hollow) in normal, operator-pending, and replace modes after entering and leaving insert mode. Root cause: the fork’s BlockCursorPlugin.update() relied on a CSS baseTheme rule to hide native cursor layers, but mode transitions (insert removes .cm-vimMode, normal re-adds it) and CM6’s drawSelection extension left native layers visible due to CSS specificity conflicts. Fixed in the fork by unconditionally hiding native CM6 cursor layers and setting caretColor to match the vim cursor color (var(--interactive-accent)) in insert mode. (#129)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (BlockCursorPlugin.update() — unconditional native layer hiding, mode-aware caretColor)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (updated setCursorSuppressed API section)
  • Doubled cursors in embedded editors (textarea vim overlay) — in the textarea vim overlay, caretColor was the accent color instead of transparent in normal mode, causing the native text caret to appear alongside the fork’s block cursor. Root cause: BlockCursorPlugin.update() checked the .cm-vimMode DOM class to determine insert/normal mode, but CM6 ViewPlugin update ordering meant the class wasn’t yet present when the block cursor plugin ran. Fixed in the fork by checking this.cm.state.vim.insertMode directly instead of the DOM class. Also uses setProperty("caret-color", ..., "important") for CSS specificity robustness. (#130)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (BlockCursorPlugin.update() — vim-state-based caretColor instead of DOM class check)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added “Vim-state-based caretColor” subsection)
  • Escape does not close footnote popover — pressing Escape twice (insert→normal, then idle normal) in the footnote popover editor did not close the popover. The user had to click outside to dismiss it. Root cause: the fork’s findKey consumed <Esc> unconditionally in idle normal mode, preventing the event from reaching Obsidian’s popover close handler. Fixed with a two-part approach: (1) the fork now exposes setIdleEscapeCallback(fn) which fires when Escape is pressed in idle normal mode, and (2) the plugin registers a callback (installEscapeGuard) that dismisses the popover via HoverPopover.hide() for non-workspace-leaf editors while silently consuming Escape in workspace-leaf editors (preventing Obsidian hotkey interference). (#130)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (setIdleEscapeCallback API, wasIdleNormal pre-capture in findKey)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added setIdleEscapeCallback API section)
    • Plugin: src/vim/escape-guard.ts (NEW — installEscapeGuard with HoverPopover.hide() dismissal)
    • Plugin: src/main.ts (installEscapeGuard(this.app) call in feature registration)
  • Invisible cursor in footnote popover with animated cursor enabled — the animated cursor canvas (z-index: 15) renders behind Obsidian’s popover (z-index: 30). The fork’s vim cursor was also suppressed (global setCursorSuppressed(true)), resulting in no visible cursor. Fixed by detecting editors inside .popover or .modal-container in the CursorController and un-suppressing the fork’s vim cursor for those views (setCursorSuppressedForView(view, false)). The animated cursor tick() skips rendering for above-canvas editors. (#130)
    • Plugin: src/vim/animated-cursor/controller.ts (isAboveCanvas flag, per-view un-suppression for popover/modal editors, tick() early return)
  • Stale cursor suppression after animated cursor toggle — when animated cursor was disabled at runtime, CursorController.update() returned early without clearing the per-view suppression override set in the constructor, leaving the fork’s vim cursor hidden. Also, the constructor unconditionally suppressed the cursor regardless of config.enabled. Fixed by gating constructor suppression on config.enabled and calling clearCursorSuppressedForView() in the disabled early-return path. (#130)
    • Plugin: src/vim/animated-cursor/controller.ts (constructor gates on config.enabled, update() clears per-view override when disabled)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated cursor shapes section with addSettingTab() caching fix (#128); added doubled cursors fix (#129); added #130 fixes (doubled cursors in embedded editors, Escape popover dismiss, invisible cursor in popovers, stale cursor suppression)
  • AGENTS.md: Updated codemirror-vim fork cursor suppression description (vim-state-based caretColor, setIdleEscapeCallback API)
  • CONTRIBUTING.md: Added escape-guard.ts to codebase structure; updated controller.ts description with isAboveCanvas flag and popover/modal fallback
  • docs/features/animated-cursor.md: Updated embeddable editors section with popover/modal fallback and z-index explanation
  • Fork DIFFERENCES.md: Added setIdleEscapeCallback API section; added “Vim-state-based caretColor” subsection; updated findKey Escape handling description

[0.111.0] - 2026-08-16

Added

  • Table-nav overlay mode — when the cursor enters a table in Live Preview, a navigation overlay activates, allowing cell navigation with h/j/k/l without entering the cell editor. Supports structural commands (o/O, dd, dc, J/K, H/L, I/A, =) and cell editing entry via i/a/c/s/Enter. Escape exits table-nav. Fork-only feature.
    • Plugin: src/vim/table-nav-controller.ts (KeyScope-based interception, fresh cmTile.widget references, hidden cell editor during navigation)
    • Plugin: src/vim/table-nav-state.ts (overlay state tracking)
    • Plugin: src/vim/table-nav-keymap.ts (navigation and structural command mappings)
    • Plugin: src/vim/native-table-adapter.ts (extended with overlay support)
    • Styles: styles.css (overlay and hidden editor styling)

Changed

  • table-cell-cursor-guard.ts — now checks isTableNavActive() to avoid cursor suppression conflicts during table navigation.
  • Cross-cell motions decoupled from table-nav — applyTableCellMotions() (h/j/k/l cross-cell navigation in native cell editors) is now gated on tableWidgetMode === 'native' only, independent of enableTableNav. Previously required both enableTableNav and tableWidgetMode === 'native'. This enables a third usage mode: native table editor with vim cell editing and cross-cell navigation, without the table-nav overlay. The enableTableNav setting now controls only the nav overlay and structural motions (]|/[|, ]c/[c).
    • Plugin: src/main.ts (both onload and reloadFeatures paths — removed enableTableNav from applyTableCellMotions gate)
    • Plugin: src/settings.ts (updated enableTableNav description in both declarative and imperative settings UI)

Fixed

  • Cursor snaps back to table after exiting table-nav — after navigating/editing in table-nav mode and exiting, the cursor could snap back to the last table cell position. Root cause: exitTable() called placeCursorAround() before destroyTableCell(), and Obsidian’s cell editor destruction triggers internal blur/focus/selection side-effects that overrode the cursor position. Fixed by reordering: destroy the cell editor first, then defer placeCursorAround() to requestAnimationFrame so Obsidian’s teardown handlers finish before the final cursor placement.
    • Plugin: src/vim/table-nav-controller.ts (exitTable — destroy-before-place, deferred cursor placement via window.requestAnimationFrame)
  • Cell-editor normal-mode navigation bypasses table-nav — when in a cell editor in normal mode (Escape pressed once to exit insert, but not again to exit cell edit), j/k/h/l at cell boundaries crossed to adjacent cells via the motion overrides, bypassing the table-nav controller. This left table-nav in an inconsistent state. Fixed by registering h/j/k/l key handlers on the cellEditScope (Obsidian Scope). When vim is idle in the cell editor, these handlers exit to nav mode and navigate within the overlay. The Scope fires before vim’s key observer, so the keys are intercepted before the motion overrides run.
    • Plugin: src/vim/table-nav-controller.ts (installCellEditScope — added h/j/k/l handlers that check isVimIdle and call exitCellEditToNav + navigate)
  • Cursor flashing in Normal mode after table interaction — the table cursor guard and table-nav controller used setCursorSuppressedForView(view, false) to unsuppress the cursor when leaving a table. This sets an explicit per-view override that conflicts with the animated cursor controller’s global suppression (setCursorSuppressed(true)), causing the native CM6 cursor to become visible and flash alongside the canvas cursor. Additionally, mainEditorTableCursorGuard.destroy() did not restore suppression state when the cursor was inside a table at destruction time, leaving a stale true override through plugin recreation. cellEditorCursorGuard.update() force-unsuppressed the cell cursor on every update cycle (same anti-pattern removed from CursorController in commit 62444df). All unsuppress paths now use clearCursorSuppressedForView() (which removes the per-view override, falling back to global state) instead of setCursorSuppressedForView(view, false). (#127)
    • Plugin: src/vim/table-cell-cursor-guard.ts (mainEditorTableCursorGuard — added constructor to store view reference; destroy() now clears per-view override and resumes animated cursor when cursorInTable is true; update() uses clearCursorSuppressedForView when leaving table; cellEditorCursorGuard — removed per-update setCursorSuppressedForView(cellView, false) force-unsuppress; destroy() uses clearCursorSuppressedForView for parent)
    • Plugin: src/vim/table-nav-controller.ts (enterCellEdit, exitTable, destroy — all use clearCursorSuppressedForView instead of setCursorSuppressedForView(view, false))
    • Plugin: src/vim/bundled-vim.ts (exposed isCursorSuppressedForView on CodeMirrorAdapter bridge for test access)

Tests

  • 6 regression tests for cursor suppression after table interaction in test/specs/table-cursor-suppression.e2e.ts (issue #127)
  • Rewrote test/specs/table-cell-vim-mode.e2e.ts “Native table cell navigation” suite for table-nav architecture: nav-mode highlight position checks (j/k/h/l), entry/exit tests (Escape, i→cell edit→Escape→nav), dd row deletion in nav mode, re-entry test (j back into table re-activates table-nav), cursor stability test (exit + insert mode round-trip doesn’t snap cursor back). Replaced “Settings gating” suite with “Table mode combinations” covering all 3 modes: native+tablenav (overlay activates), native+notablenav (cross-cell j/k/h/l works without overlay), raw (widget hidden)
  • Fixed 3 spike test files (spike-table-fresh-ref, spike-cell-introspect, spike-table-nav-overlay) by disabling table-nav in before hooks so cell editor introspection tests can access editMode.tableCell directly
  • Fixed spike-table-nav-overlay editorInfoField test: use obsidian.editorInfoField from executeObsidian callback instead of window.require('obsidian')

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added cursor-snap fix; updated table widget section with 3-mode table; updated cross-cell motion decoupling
  • AGENTS.md: Updated table-nav-controller.ts description with deferred exit flow; updated table-cell-motions gating
  • CONTRIBUTING.md: Updated table-nav-controller.ts and table-cell-motions.ts descriptions
  • README.md: Updated table editing feature description with 3 mode combinations
  • docs/features/tables.md: Updated table widget section with 3-mode architecture; clarified enableTableNav vs cross-cell motions
  • docs/configuration/settings.md: Updated enableTableNav description

[0.110.0] - 2026-08-14

Added

  • Animated cursor: cross-cell position handoff — when navigating between table cells via h/j/k/l, a token-based handoff seeds the new cell’s CursorController with the previous cell’s screen position via the AnimatedCursorManager singleton. The handoff infrastructure is in place but the canvas transition animation is not visible due to CSS stacking contexts (the canvas at position: fixed on .app-container renders behind table cell content). The native vim cursor (BlockCursorPlugin) serves as the steady-state renderer inside cells. See KNOWN_LIMITATIONS.md for details.
    • Plugin: src/vim/animated-cursor/manager.ts (CellCrossingHandoff interface, createCrossingToken/storeCrossingHandoff/consumeCrossingHandoff methods, signalCellCrossing/getPendingCrossingToken/clearPendingCrossingToken module-level functions)
    • Plugin: src/vim/animated-cursor/controller.ts (cellTransitionActive flag, crossing token consumption in constructor, position handoff in destroy, cell-aware tick/update that skips canvas drawing when no transition is active)
    • Plugin: src/vim/table-cell-motions.ts (signalCellCrossing() call in scheduleCrossing)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (table cell override documented in DIFFERENCES.md)
    • Styles: styles.css (animated cursor canvas z-index bumped from 5 to 15)

Changed

  • Table editing: migrated to native Obsidian table editor — the plugin no longer suppresses Obsidian’s cm-table-widget or provides custom cell editors. In Live Preview, Obsidian’s native table editor handles cell editing, pipe escaping, wikilinks, cursor positioning, and <br> conversion. Vim is injected into native cell editors via registerEditorExtension(). The tableWidgetMode setting is simplified from 4 values (off/cursor/always/embedded) to 2 (native/raw). Old values are automatically migrated.
    • Removed: src/vim/table-widget-suppressor.ts (RangeSetBuilder monkey-patch)
    • Removed: src/vim/table-render-widget.ts (custom widget)
    • Removed: src/vim/table-nav-controller.ts (custom nav state machine)
    • Removed: src/vim/table-cell-editor.ts (custom cell editors)
    • Removed: src/vim/table-embedded-editor.ts (configuration bridge)
    • Added: src/vim/table-nav-overlay.ts (native TableEditor API overlay)
    • Added: src/vim/native-table-adapter.ts (typed abstraction layer)
    • Added: src/types/table-editor.d.ts (runtime-discovered typings for 55 native methods)

Fixed

  • Escape in hint mode exits embedded vim editor — pressing Escape to dismiss hint mode while inside an embedded vim editor (textarea vim overlay, Oil explorer, table cell editor) also exited the embedded editor. Root cause: Obsidian’s Scope keymap handlers fire independently of DOM event propagation — stopPropagation() in hint mode’s capture-phase listener does not prevent the Scope handler from receiving the event. The embedded editor’s Scope handler checked isVimIdle() (which returns true during hint mode, since hint mode is a plugin-level overlay, not a vim state) and called onEscape(). Fixed by adding a guard in the embedded editor’s Escape handler that checks isHintModeActive(), isEasyMotionActive(), and isFlashActive() before evaluating isVimIdle(). (#126)
    • Plugin: src/editors/embeddable-editor.ts (Scope Escape handler — modal overlay active guard before isVimIdle() check)
  • Wikilinks in table cells work correctly — cursor displacement when typing [[ in table cells is fixed. The native editor handles wikilink rendering at the decoration layer, eliminating the sub-CM6 cursor displacement that affected the old custom widget.
  • Pipe character (|) no longer swallowed in table cells — the native editor automatically escapes | as \| in the document source. Previously, Obsidian’s DOM-level table editor intercepted | before CM6’s input pipeline.
  • <br> conversion handled natively — newlines in table cells are automatically converted to/from <br> by the native editor. The cellBrToNewline/cellNewlineToBr utilities are removed.
  • Embedded table: Obsidian shortcuts (Ctrl+P, Cmd+O) now work in cell selection mode — modifier key combos in table-nav mode now call e.stopPropagation() to prevent vim’s eventObservers.keydown from consuming them as cursor movement, then manually feed the event to Obsidian’s keymap system via app.keymap.onKeyEvent(e). This two-step approach blocks vim (which would process <C-p> as cursor-up) while still triggering Obsidian’s hotkey bindings (command palette, file switcher, custom hotkeys). Uses the unofficial Keymap.onKeyEvent API from obsidian-typings. (#120)
    • Plugin: src/vim/table-nav-controller.ts (handleTableNavKey — stopPropagation + app.keymap.onKeyEvent for modifier combos)
  • Embedded table: ex command dialog keys no longer consumed by table-nav — when vim’s ex command dialog is open (after pressing :), table-nav keys like h, j, k, l, a, i, c are no longer intercepted by the table-nav handler. The handler now checks adapter.state.dialog and returns early when a dialog is active. (#120)
    • Plugin: src/vim/table-nav-controller.ts (handleTableNavKey — adapter.state.dialog check)
  • Insert mode escape sequence and other vim engine settings not applied after restart — six vim engine settings (insertmodeescape, insertmodeescapetimeout, operatorshadowtimeout, tabstop, shiftwidth, expandtab) configured via the Settings UI were not synced to the vim engine on plugin load. Only clipboard, textwidth, and pcre were synced at startup. The remaining settings were stored in data.json but never pushed to vim.setOption() during initialization, so they silently reverted to defaults on every Obsidian restart. The vimrc/Lua code path was unaffected because it calls vim.setOption() directly. Additionally, on Obsidian 1.13+, the declarative settings system (setControlValue) did not forward any of the 9 vim engine settings to vim.setOption() — only the pre-1.13 imperative onChange handlers did. (#125)
    • Plugin: src/main.ts (added init sync for insertmodeescape, insertmodeescapetimeout, operatorshadowtimeout, tabstop, shiftwidth, expandtab after registerVimOptions())
    • Plugin: src/settings.ts (VIM_OPTION_KEYS static set; setControlValue forwards vim engine settings to vim.setOption() with setClipboardOption/setTextwidth side effects)

Tests

  • 1 regression test for hint mode Escape in test/specs/textarea-vim.e2e.ts (issue #126): Escape in hint mode dismisses hint overlay but does not exit the textarea vim overlay — verified to fail without the modal overlay guard
  • 1 regression test for modifier combo in test/specs/table-cell-vim-mode.e2e.ts: modifier combo does not move cursor during cell selection
  • 1 regression test for ex dialog in test/specs/table-cell-vim-mode.e2e.ts: keys with table-nav meaning do not change active cell when ex dialog is open (verified to fail without dialog check)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added hint mode Escape fix to textarea vim Escape behavior section (#126)
  • AGENTS.md: Updated embeddable-editor description with modal overlay active guard
  • CONTRIBUTING.md: Updated embeddable-editor description with modal overlay active guard
  • docs/features/hint-mode.md: Added note about embedded editor Escape isolation
  • KNOWN_LIMITATIONS.md: Updated vim engine settings section — all 9 settings now synced at init (was 3); added declarative settings forwarding fix for Obsidian 1.13+ (#125)
  • KNOWN_LIMITATIONS.md: Added animated cursor cross-cell transition as known limitation in table cell vim modality section
  • CONTRIBUTING.md: Added table-cell-motions.ts and table-cell-cursor-guard.ts to codebase structure; updated manager.ts with cross-cell handoff API; updated controller.ts with cell transition architecture
  • AGENTS.md: Updated animated cursor page ownership (unchanged — features/animated-cursor.md)
  • docs/features/animated-cursor.md: Updated embeddable editors section with cross-cell transition details and known limitation
  • docs/features/tables.md: Added animated cursor note to cell editor section
  • Fork DIFFERENCES.md: Added table cell override section documenting BlockCursorPlugin’s unsuppress behavior for .cm-table-widget editors

[0.109.1] - 2026-08-13

Fixed

  • Embedded table: modifier key combos (Ctrl+P, Cmd+O) consumed by vim during cell selection — modifier key combos in table-nav mode now call e.stopPropagation() to prevent vim’s eventObservers.keydown from processing them as cursor movement commands (e.g., <C-p> mapped to cursor-up). Previously, pressing Ctrl+P in cell selection mode moved the cursor up instead of opening the command palette. The stopPropagation() blocks the event from reaching vim’s observer on the CM6 editor element while allowing Obsidian’s hotkey system (which uses Electron’s before-input-event) to handle it. (#120)
    • Plugin: src/vim/table-nav-controller.ts (handleTableNavKey — stopPropagation() for modifier key combos)

[0.109.0] - 2026-08-12

Added

  • Embedded table: click-to-select cell — clicking a cell in the embedded table widget now selects that cell in table-nav mode. Works both when table-nav is already active (updates active cell) and when clicking from outside the table (enters table-nav at the clicked cell). The click handler is registered on the widget DOM via a module-level setTableWidgetCellClickHandler callback, coordinated between table-render-widget.ts and table-nav-controller.ts. (#120)
    • Plugin: src/vim/table-render-widget.ts (setTableWidgetCellClickHandler, click handler in toDOM for embedded mode)
    • Plugin: src/vim/table-nav-controller.ts (constructor registers callback; pendingClickCell field for deferred cell selection on table-nav entry)

Fixed

  • Embedded table: click-outside handler exits table-nav during modal interaction — the capture-phase mousedown listener now checks for .modal-container in the DOM before calling exitTable(). Previously, opening a modal (command palette, picker, settings) while in table-nav mode caused the click-outside handler to fire on the modal overlay, exiting table-nav and showing raw markdown. (#120)
    • Plugin: src/vim/table-nav-controller.ts (installClickOutsideHandler — modal container check + target.closest('.modal-container') guard)
  • Embedded table: header-only tables (no data rows) no longer enter table-nav — tables with only a header and separator row (e.g., | H |\n|---|) are now skipped by checkEntry(). Previously, entering such a table activated table-nav with only the header row navigable, which was confusing. (#121)
    • Plugin: src/vim/table-nav-controller.ts (checkEntry — hasDataRow check before enterTableNav)

Tests

  • 1 regression test for click-to-select in test/specs/table-cell-vim-mode.e2e.ts: clicking a cell updates active cell highlight to clicked position (verified to fail without handler)
  • 1 regression test for header-only table in test/specs/table-cell-vim-mode.e2e.ts: header-only table does not enter table-nav (verified to fail without data-row check)
  • 1 regression test for click-outside table in test/specs/table-cell-vim-mode.e2e.ts: cursor leaving table exits table-nav

[0.108.0] - 2026-08-12

Added

  • @obsidian-typings/obsidian-public-latest devDependency — added community-maintained type definitions for Obsidian’s internal APIs. Replaces ~50 unsafe as unknown as casts across 13 source files with properly typed access to editor.cm, app.keymap, app.plugins, app.vault.getConfig(), app.metadataCache.resolvedLinks, WorkspaceLeaf.id/.pinned, and more. Build-only dependency — no runtime impact.
    • Plugin: package.json (@obsidian-typings/obsidian-public-latest devDependency)
    • Plugin: tsconfig.json ("types": ["@obsidian-typings/obsidian-public-latest"])
    • Plugin: src/util/editor.ts, src/util/keymap.ts, src/util/leaf.ts, src/util/metadata.ts, src/util/vault.ts, src/ui/global-ex-command.ts, src/ui/hint-mode.ts, src/workspace/commands.ts, src/lua/loader.ts, src/main.ts, src/vim/vim-api.ts, src/picker/sources/dataview.ts (cast removal)

Fixed

  • Obsidian native highlights not cleared on Escape — Obsidian’s is-flashing highlights (shown after following an internal link to a heading like [[Note#heading]]) now clear when Escape is pressed in normal mode. Previously, these highlights persisted until the user clicked elsewhere. Uses the unofficial editor.removeHighlights('is-flashing') API (documented in obsidian-typings, used by obsidian-quiet-outline and others). (#122)
    • Plugin: src/vim/mode-tracker.ts (clearNativeHighlights method; vim-keypress handler clears is-flashing on <Esc> in normal mode)
  • Chord display breaks during surround commands — the status bar chord display now correctly accumulates all pending keystrokes during multi-key surround commands like ysiwb, cs"(, yss", and count-prefixed variants like 2ysiw*. Previously, the chord disappeared after the surround sub-state was entered (e.g., ys showed correctly but ysi was blank). (#123)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (processAction — saves and restores vim.status around clearInputState when vim.surroundState is pending; handleSurroundSubState — clears vim.status on surround completion)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added “Chord display preservation during surround sub-state” section)
  • Embedded table: table-nav key handler suppressed during modals — handleTableNavKey and handleCellEditKey now check for .modal-container in the DOM and return immediately when a modal (picker, command palette, settings) is open. Previously, keys typed into a picker input while in cell selection mode were consumed by the table handler (a entered cell edit, s substituted, etc.). (#120)
    • Plugin: src/vim/table-nav-controller.ts (handleTableNavKey and handleCellEditKey — modal container check)
  • Cursor-aware table: cursor displacement guard for header-row jump — a transactionFilter (tableCursorGuard) intercepts CM6 transactions that reposition the cursor to the table header row when the user was editing a data row. This prevents Obsidian’s Live Preview from snapping the cursor to the header during table creation or editing. Only active in cursor-aware mode (not embedded mode). (#121)
    • Plugin: src/vim/table-render-widget.ts (tableCursorGuard transaction filter, separated from Prec.high StateField)

Tests

  • 2 e2e tests in test/specs/native-highlight-escape.e2e.ts (issue #122): Escape in normal mode clears is-flashing highlight, Escape without highlights does not error
  • 7 e2e tests in test/specs/surround-chord-display.e2e.ts (issue #123): ysiwb/ysiw"/yse) chord accumulation at each keystroke, yss" chord accumulation, ds" chord (passing baseline), cs"( chord accumulation, 2ysiw* count-prefixed chord accumulation

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added #122 native highlight clearing as fixed; updated chord display section with surround sub-state fix (#123)
  • AGENTS.md: Added @obsidian-typings/obsidian-public-latest to environment & tooling; updated mode-tracker description with clearNativeHighlights
  • CONTRIBUTING.md: Updated utility function descriptions to reflect typed access via obsidian-typings; updated mode-tracker description

[0.107.0] - 2026-08-12

Fixed

  • Embedded table: cannot leave table downwards on last line — pressing j at the last data row when the table is at the end of the document now inserts a newline and moves the cursor below the table instead of getting stuck. (#119)
    • Plugin: src/vim/table-nav-controller.ts (exitTableAtBoundary — inserts \n when table is on last line instead of dispatching to doc.length)
  • Embedded table: unhandled keys swallowed in cell selection mode — the table-nav key handler previously consumed ALL keys with preventDefault()/stopPropagation(). Now only consumes keys the handler actually processes; unhandled keys propagate to vim. Enables leader key sequences, which-key popups, and other vim key bindings during cell selection. (#120)
    • Plugin: src/vim/table-nav-controller.ts (handleTableNavKey — handled flag gates preventDefault/stopPropagation; pendingD default case propagates instead of consuming)
  • Embedded table: which-key popups in cell selection mode — a WhichKeyOverlay instance is now attached during table-nav mode using the main editor’s vim adapter. Previously only available in cell-edit mode. (#120)
    • Plugin: src/vim/table-nav-controller.ts (attachNavWhichKey/detachNavWhichKey, setTableNavWhichKeyConfig)
    • Plugin: src/vim/table-embedded-editor.ts (re-exports setTableNavWhichKeyConfig)
    • Plugin: src/main.ts (wires embeddedWhichKeyConfig to table-nav controller)
  • Embedded table: picker focus stays on table widget — the setActiveLeaf override in embeddable-editor.ts now allows focus transfer when a modal is open by checking for .modal-container in the DOM. (#120)
    • Plugin: src/editors/embeddable-editor.ts (setActiveLeaf override — modal container check)
  • Embedded table: clicking outside table does not exit table-nav — a capture-phase mousedown listener on activeDocument exits table-nav when clicks land outside the widget. (#121)
    • Plugin: src/vim/table-nav-controller.ts (installClickOutsideHandler/removeClickOutsideHandler)
  • Embedded table: stale table-nav state after document replacement — the ViewPlugin’s update() now detects when docChanged fires and the cursor is no longer in a table, exiting table-nav gracefully. (#119, #120)
    • Plugin: src/vim/table-nav-controller.ts (update — stale state check on docChanged)
  • Embedded table: cursor displacement when entering table-nav — setActiveEditTableRange() is now called before this.view.dispatch() in enterTableNav(), preventing decoration rebuild from snapping the cursor to the header row. (#121)
    • Plugin: src/vim/table-nav-controller.ts (enterTableNav — reordered setActiveEditTableRange before dispatch)
  • Embedded table: Escape in table-nav uses Obsidian Scope — table-nav mode now installs an Obsidian Scope with an Escape handler (matching the cell-editor pattern), in addition to the DOM capture-phase handler. (#120)
    • Plugin: src/vim/table-nav-controller.ts (installNavScope/removeNavScope)

Tests

  • Unblocked 13 previously-skipped embedded table widget e2e tests in test/specs/table-cell-vim-mode.e2e.ts — root cause: missing browser.reloadObsidian() and table-only document content preventing widget rendering. All cell editing tests (two-Escape, entry modes, register sharing, <br> round-trip, multi-table navigation) now pass.
  • 2 regression tests for #119 in test/specs/table-cell-vim-mode.e2e.ts: j at last row exits table at end of document, cursor usable after exit
  • 2 regression tests for #120 in test/specs/table-cell-vim-mode.e2e.ts: Escape in cell selection mode (skipped — WDIO DOM Escape routing limitation), unhandled keys not swallowed
  • 3 regression tests for #121 in test/specs/table-cell-bridge.e2e.ts: j moves through proper table rows, cursor not stuck on header, cursor not jumping back

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Marked 7 table widget sub-issues as fixed (#119, #120, #121); updated embedded table e2e test coverage note
  • AGENTS.md: Updated table-nav-controller description with click-outside handler, Scope-based Escape, which-key in table-nav, stale state cleanup
  • CONTRIBUTING.md: Updated table-nav-controller.ts description

[0.106.0] - 2026-08-11

Fixed

  • Bundled table/table3 snippets missing trailing newline — the $0 final tabstop was inline on the last table row. When a table was inserted at the end of a document, there was no line below it, preventing cursor movement past the table. Fixed by adding a standalone $0 as a separate final body element, matching the pattern used by the Frontmatter snippet. (#118)
    • Plugin: src/snippets/bundled/obsidian-markdown.json (Table 2x2, Table 3x3 — moved $0 from inline on last row to standalone final element)
  • User-defined snippets duplicate bundled snippets instead of overriding them — when a user defined a snippet with the same prefix as a bundled one (e.g., table), both appeared in the completion menu and picker instead of the user snippet replacing the bundled one. Root cause: addToPrefixIndex() in SnippetRegistry inserted user entries before bundled entries but never removed the bundled entry, and entry IDs were source-qualified (bundled:Table 2x2 vs user:My Table) so both coexisted in the entries Map. Fixed with priority-based override logic (user > lua > bundled): when a higher-priority source registers a prefix colliding with a lower-priority entry, the lower-priority entry is removed from the prefix index and, if orphaned (no remaining prefixes), from the entries Map. (#118)
    • Plugin: src/snippets/registry.ts (sourcePriority static method; addToPrefixIndex rewritten with priority-based filtering, orphan cleanup, and priority-sorted insertion)

Tests

  • 10 unit tests in test/unit/snippets/registry.test.ts (issue #118): basic load and prefix indexing, user overrides bundled, lua overrides bundled, user overrides lua, full priority chain (user > lua > bundled), same-priority coexistence, multi-prefix partial overlap (keeps non-overlapping prefix), multi-prefix full overlap (removes orphaned entry), no-collision coexistence, bundled table snippet trailing newline validation
  • 7 e2e tests in test/specs/snippets/snippet-override.e2e.ts (issue #118): table snippet trailing newline via Tab, table3 snippet trailing newline via :snippet, table at end of document produces content after last row, user override expands correct body, lookupByPrefix returns only user entry, getAll excludes shadowed bundled entry, non-overridden bundled snippet still works

Documentation

  • CHANGELOG.md
  • CONTRIBUTING.md: Added registry.ts to snippets codebase structure with priority-based override description
  • docs/features/snippets.md: Expanded override behavior documentation with priority order and Lua snippet override semantics

[0.105.1] - 2026-08-11

Fixed

  • Embedded table mode: cursor jumps to first table after cell edit — follow-up to the multi-table fix in 0.105.0. After editing a cell in the second (or later) table and pressing Escape twice, the cursor jumped back to the first table. Two sub-bugs: (1) activeEditTableRange was cleared before closeCellEditor/tableRealign dispatches, causing buildDecorations to create a Decoration.replace for the active table and displacing the cursor — fixed by keeping activeEditTableRange set throughout the exit and refresh lifecycle. (2) After tableRealign, doRefreshAfterOp used Array.find() with a 200-position threshold to re-locate the table, which returned the first table within range rather than the closest — fixed by replacing with a nearest-match loop. (#117)
    • Plugin: src/vim/table-nav-controller.ts (exitCellEdit — removes premature setActiveEditTableRange(null) clear; doRefreshAfterOp — sets activeEditTableRange before tableRealign dispatch, replaces Array.find with nearest-match loop for post-realign table re-location)

Tests

  • 4 e2e test cases in test/specs/table-cell-vim-mode.e2e.ts (issue #117, skipped — embedded widget rendering limitation in WDIO): entry into second table highlights correct widget, cell editor opens on second table not first, add row affects only second table, first table unaffected during second table navigation

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated multi-table fix with activeEditTableRange lifecycle and Array.find nearest-match fixes
  • CONTRIBUTING.md: Updated table-nav-controller.ts description
  • docs/features/tables.md: Added multi-table support note to embedded mode section

[0.105.0] - 2026-08-11

Added

  • Which-key popups in embedded editors — which-key hints now appear in table cell editors (embedded mode) and textarea vim overlays. The popup renders in the parent note’s viewport using the same position and styling as the main editor’s which-key. User keymaps (vimrc, Lua) are fully available since the codemirror-vim keymap is global. Bundled vim mode only — embedded editors in built-in vim mode do not receive vim and are silently skipped.
    • Plugin: src/ui/which-key.ts (WhichKeyConfig exported interface; WhichKeyOverlay.forEmbeddedEditor() static factory for dependency injection; attach() injected-mode early return; showOverlay() injected container fallback with status-bar padding guard; onKeyPressGeneral() delay bypass for embedded editors; detachAdapter() try/catch for destroyed adapters; destroy() clears injected references)
    • Plugin: src/vim/table-cell-editor.ts (setCellEditorWhichKeyConfig() exported setter; openCellEditor() deferred which-key creation via setTimeout(0); closeCellEditor() which-key cleanup before editor destroy)
    • Plugin: src/vim/textarea-vim-manager.ts (whichKeyConfig class field; updateOptions() extended with which-key config; ActiveReplacement.whichKey field; replace() deferred which-key creation with .view-content → .modal-container fallback; teardownActive() which-key cleanup)
    • Plugin: src/main.ts (WhichKeyConfig import; setCellEditorWhichKeyConfig import; embedded config construction and wiring after WhichKeyOverlay creation)

Fixed

  • Embedded table mode does not handle multiple tables per note — in embedded table widget mode (set tablewidget=embedded), when a note contained two or more tables, entering table-nav mode on any table other than the first always attached the cell highlight, key handlers, and cell editor to the first table’s DOM widget. Entering from below selected the last cell of the first table. Root cause: findWidgetEl() in table-nav-controller.ts queried all .vim-table-rendered elements and returned the first match without considering which TableRange the cursor was in. Fixed by adding a tableFrom parameter to findWidgetEl() and using CM6’s view.posAtDOM() to correlate each widget element with its document position, returning the nearest match to the active table’s from offset. (#117)
    • Plugin: src/vim/table-nav-controller.ts (findWidgetEl — accepts optional tableFrom parameter, uses posAtDOM nearest-match with try/catch for detached elements; enterTableNav — passes table.from explicitly; devAssert import and __DEV__ assertion verifying widget position matches active table)
  • Enter in embedded table cell editor breaks table structure — pressing Enter in insert mode inside an embedded table cell editor (set tablewidget=embedded) inserted a literal newline into the cell content. Upon exiting the table, the multi-line content was written back into the single-line markdown table row, breaking the table structure — the second line appeared outside the table. Fixed by converting newlines to <br> tags on cell editor close and converting <br> tags back to newlines on cell editor open, preserving multi-line cell content using standard HTML line breaks that Obsidian renders correctly within table cells. Existing <br> content in cells round-trips cleanly. (#115)
    • Plugin: src/vim/table-utils.ts (cellBrToNewline, cellNewlineToBr — new pure utility functions for <br> ↔ newline conversion)
    • Plugin: src/vim/table-cell-editor.ts (openCellEditor — converts <br> to newlines on open; closeCellEditor — converts newlines to <br> on close)

Tests

  • 4 e2e test cases in test/specs/table-cell-vim-mode.e2e.ts (issue #117, skipped — embedded widget rendering limitation in WDIO): entry into second table highlights correct widget, cell editor opens on second table not first, add row affects only second table, first table unaffected during second table navigation
  • 8 e2e tests in test/specs/textarea-vim-which-key.e2e.ts: which-key appears after partial chord (d, g) in normal mode, dismisses on command completion (dd), dismisses on Escape, suppressed in insert mode, suppressed when whichKeyMode is off, cleans up on editor close (blur), cleans up on modal removal
  • 14 unit tests in test/unit/table-cell-br.test.ts (issue #115): cellBrToNewline (7 tests — <br>, <br/>, <br />, case-insensitive, multiple tags, no-op, empty string), cellNewlineToBr (4 tests — single/multiple newlines, no-op, empty string), round-trip (3 tests — newline→br→newline, br→newline→br, mixed markdown content)
  • 2 e2e test cases in test/specs/table-cell-vim-mode.e2e.ts (issue #115, skipped — embedded widget rendering limitation in WDIO): Enter in cell editor produces <br> and keeps table valid, round-trip of existing <br> in cell content

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Marked multi-table widget selection as fixed with posAtDOM position matching; marked Enter-in-cell-editor table breakage as fixed with <br> conversion; added which-key in embedded editors note to table cell section
  • CONTRIBUTING.md: Updated table-nav-controller.ts description with posAtDOM-based widget matching for multi-table support; updated table-utils.ts description with cellBrToNewline/cellNewlineToBr helpers; updated which-key.ts, table-cell-editor.ts, and textarea-vim-manager.ts descriptions with which-key overlay lifecycle
  • AGENTS.md: Updated dual-vim architecture section with which-key in embedded editors via dependency injection
  • docs/features/tables.md: Added multi-table support note to embedded mode section; added multi-line cell content note with <br> support in embedded mode; added which-key support note to embedded cell editor section
  • docs/configuration/which-key.md: Added embedded editors section documenting which-key in table cell editors and textarea vim overlays

[0.104.0] - 2026-08-10

Added

  • vimHandleKeys test helper — new helper in test/helpers.ts that dispatches all keys synchronously through Vim.handleKey() in a single executeObsidian callback, bypassing DOM event timing. Used for visual-mode compound operations that fail with vimRawKeys DOM dispatch.
    • Plugin: test/helpers.ts (vimHandleKeys function)
  • useHandleKey flag on TestCaseDefinition — test cases can opt in to vimHandleKeys dispatch via useHandleKey: true. testWithNeovim checks this flag and routes to vimHandleKeys instead of dispatchVimKeys.
    • Plugin: test/neovim/test-definitions.ts (useHandleKey?: boolean on interface, set on 6 visual-mode test cases)
    • Plugin: test/neovim/test-wrapper.ts (import vimHandleKeys, useHandleKey in config type, dispatch branching)
  • Deviation category classification — all deviations in deviations.ts now have a category field (intentional, infra-limitation, upstream-bug, upstream-unsupported, recording-issue). findDeviation() export added. [INFRA-SKIP] console warnings emitted for infra-limitation deviations.
    • Plugin: test/neovim/deviations.ts (interface + 25 entries classified + findDeviation())
    • Plugin: test/neovim/test-wrapper.ts (infra-skip logging in both live and golden paths)
  • Golden schema extended with registers and visualMode — GoldenCase.result now includes optional registers (unnamed register text + linewise flag) and visualMode (charwise/linewise/blockwise). Recording captures these fields. Comparison is not yet enabled (register state leaks between tests in shared Obsidian session).
    • Plugin: test/neovim/compare.ts (EditorState extended, getObsidianState/getNeovimState capture registers and visual sub-mode)
    • Plugin: test/neovim/golden.ts (GoldenCase extended)
    • Plugin: test/neovim/record-golden.ts (captures registers and visual mode)
    • Plugin: test/neovim/client.ts (getRegisterType, getRawMode methods)
    • Plugin: test/neovim/golden-data/*.json (24 files re-recorded with new fields)
  • Golden mode comparison — testWithNeovim() golden path now compares mode in addition to content and cursor. Mode mismatches that were previously invisible are now caught.
    • Plugin: test/neovim/test-wrapper.ts (mode comparison in golden path)
  • else { throw } guards on all 26 SUITES.find() files — if a suite name is renamed in test-definitions.ts but not in the spec file, the test runner produces an explicit failure instead of silently generating zero tests.
    • Plugin: test/specs/vim-builtin/*.e2e.ts (26 files)

Changed

  • E2E test assertions strengthened — 35 tests that previously only checked mode === 'normal' or assertPluginLoaded() now have content, cursor, or behavioral assertions. 16 workspace-layout ex-command tests renamed with [crash-guard] prefix. 1 tautological assertion fixed (toBeGreaterThanOrEqual(0) → toBe(0)).
    • Plugin: test/specs/undo-tree.e2e.ts, test/specs/undo-tree-navigation.e2e.ts, test/specs/vim-builtin/ex-commands-expanded.e2e.ts, test/specs/vimrc.e2e.ts
  • Deviation count reduced — 6 visual-mode infra-limitation deviations resolved via useHandleKey (V3j+J, vip+d, v+r, v+aw+d, vt.+d, v$+d). 1 deviation resolved via key-string fix (lua nmap change word — <Esc> literal → \x1b byte). 1 reclassified from infra-limitation to upstream-bug (lua leader key mapping).

Fixed

  • Flash labels missing from top half of viewport with frontmatter scrolled off-screen — in Live Preview mode, when YAML frontmatter properties (~10-15 lines) were collapsed into a widget and scrolled off-screen, flash f/F/t/T labels only appeared in the bottom half of the viewport. The number of missing lines matched the frontmatter line count. Root cause: getVisibleRange() in src/easymotion/targets.ts used view.lineBlockAtHeight() which relies on CM6’s height map — when the collapsed frontmatter widget was off-screen, height estimation errors caused coordsAtPos() to return null for targets near the viewport top, and filterVisibleTargets() dropped them. Fixed by using view.visibleRanges (actually-rendered document ranges) instead of lineBlockAtHeight. Also affected EasyMotion target scanning. (#114)
    • Plugin: src/easymotion/targets.ts (getVisibleRange — replaced lineBlockAtHeight with view.visibleRanges)
    • Plugin: test/unit/flash-targets.test.ts (updated CM6 stub to provide visibleRanges)
  • v$d cursor off-by-one — visual-mode v$d left cursor at ch:5 instead of ch:4 after deleting to end of line. Root cause: clipCursorToContent in the delete operator ran while vim.visualMode was still true (allowing ch = text.length), and exitVisualMode ran after the operator returned without re-clamping. Fixed by re-clipping operatorMoveTo through clipCursorToContent after exitVisualMode in applyOperator.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (applyOperator — re-clip cursor after exitVisualMode)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added “Visual operator cursor re-clamping after exitVisualMode” section)
  • s substitute consumed by flash jump in Tier 1 tests — the s key did nothing in normal-editing.e2e.ts because the test vault had flashJumpEnabled: true, which mapped s to flash jump mode instead of the built-in substitute (cl). Flash jump tests explicitly enable this setting in their own before() hooks. Fixed by setting flashJumpEnabled: false in data.json and adding a defensive disable in the test’s before() hook.
    • Plugin: test-vault/.obsidian/plugins/vim-motions/data.json (flashJumpEnabled: false)
    • Plugin: test/specs/vim-builtin/normal-editing.e2e.ts (defensive flash disable in before())
  • vt.d on multi-dot content consumed by flash labels — vt.d on content with 2+ dot characters (e.g., foo.bar.baz) deleted only 1 character because flash motions showed labels for the multiple . matches, consuming the d key as a label character. Not a fork bug — flash working as designed (same as flash.nvim in Neovim). Fixed by setting enableFlash: false in test vault data.json and disabling flash in visual-mode.e2e.ts before() hook. Flash-specific behavior is tested in dedicated test files.
    • Plugin: test-vault/.obsidian/plugins/vim-motions/data.json (enableFlash: false)
    • Plugin: test/specs/vim-builtin/visual-mode.e2e.ts (defensive flash disable in before())

Tests

  • 1 e2e test in test/specs/flash-frontmatter-viewport.e2e.ts (issue #114): flash labels appear in top half of viewport when frontmatter properties are scrolled off-screen in Live Preview mode — verifies labels are not clustered in bottom half only

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added “E2E test infrastructure weaknesses” section with fixed/remaining items; marked s substitute test failure as fixed; updated deviation-masked operations count and root causes; marked flash frontmatter viewport offset as fixed
  • AGENTS.md: Updated deviation registry description with categories and [INFRA-SKIP]; updated test helpers with vimHandleKeys; updated targets.ts description with visibleRanges
  • CONTRIBUTING.md: Added vimHandleKeys to helper list; added useHandleKey flag documentation; updated deviations.ts description with categories; updated targets.ts description with visibleRanges
  • DIFFERENCES.md (fork): Added “Visual operator cursor re-clamping after exitVisualMode” section

[0.103.0] - 2026-08-08

Fixed

  • Native Obsidian shortcuts (Tab, Shift+Tab, Ctrl+Shift+I, F-keys) consumed in Normal/Visual mode — since v0.99.0, unmapped functional keys were silently swallowed in Normal and Visual modes but worked in Insert mode. Root cause: the fork’s findKey guard (commit 4aa1cc7) used /^<.+>$/ to suppress unmatched angle-bracket keys in normal mode, which consumed ALL angle-bracket keys — including <Tab>, <S-Tab>, <C-S-I>, <F1>–<F12>, and other keys that should propagate to the host application. The guard was intended to catch <Space> (which bypassed the original key.length === 1 check), but the regex was too broad. Fixed by narrowing to a whitelist of text-producing special keys (<Space>, <BS>, <Del>, <CR>) plus keys that must not propagate to the host (<Esc>, <Ins>), and preserving the Mac Alt character guard (<A-x>) from upstream PR #194. <Esc> is included because handleEsc() returns undefined in idle normal mode (intentional no-op), but the keydown event must still be consumed to prevent it from propagating to the DOM and triggering modal closes or scope pops. Functional/navigation keys now return undefined from findKey, allowing Obsidian to handle them natively. (#113)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (findKey — narrowed key consumption guard from /^<.+>$/ to /^<(Space|BS|Del|CR|Esc|Ins)>$/ + Mac <A-.> guard)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (updated “Unmatched angle-bracket keys consumed in normal mode” section)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated v0.99.0 <Space> fix description — narrowed guard to prevent consuming functional keys
  • AGENTS.md: Updated codemirror-vim fork description with narrowed key consumption guard

[0.102.0] - 2026-08-08

Fixed

  • Vim p with non-text clipboard content silently does nothing — when clipboard=unnamed or clipboard=unnamedplus is set, pressing p (or ]p, [p, :put) with an image on the system clipboard did nothing. The fork’s paste action called navigator.clipboard.readText() which returns "" for image-only clipboard content, and continuePaste() bailed on the empty string with no error handling. Fixed by adding a .catch() handler to the readText() promise and a fallbackToNativePaste() method that calls document.execCommand('paste') when the text clipboard is empty or readText() rejects. This triggers Obsidian’s native paste pipeline, which creates an attachment and inserts ![[Pasted image …]]. A programmaticPaste flag suppresses the fork’s getOnPasteFn listener during the fallback to prevent spurious insert-mode entry. The editor stays in normal mode after the fallback. Covers p, ]p, [p, :put, and explicit "+p register paste. P/gp/gP are overridden by the host plugin’s pasteFromRegister() and are not affected (separate issue).
    • Fork: ~/Repos/codemirror-vim/src/vim.js (programmaticPaste flag, getOnPasteFn guard, paste action rewrite with .catch(), fallbackToNativePaste method)
    • Fork: ~/Repos/codemirror-vim/src/types.ts (fallbackToNativePaste type signature in vimActions)

Tests

  • 3 e2e spike tests in test/specs/spikes/spike-execcommand-paste.e2e.ts: execCommand('paste') with image clipboard triggers native image paste, execCommand('paste') with text clipboard inserts text (control), vim p with image clipboard and clipboard=unnamed inserts image via native fallback

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added clipboard non-text paste fallback fix to yank-ring section
  • AGENTS.md: Updated codemirror-vim fork description with fallbackToNativePaste and programmaticPaste flag
  • docs/configuration/settings.md: Added note about non-text clipboard fallback to clipboard setting description

[0.101.0] - 2026-08-08

Fixed

  • Escape exit from textarea vim overlay leaks to parent scope — pressing Escape twice (insert → normal → exit) in the textarea vim editor caused the Escape keydown event to propagate to the parent modal’s DOM, closing it or switching the active leaf. Root cause: handleEscapeAndRedispatch() called teardownActive() synchronously inside the Obsidian Scope.register handler, which destroyed the editor and popped the keymap scope mid-handler. The isolateKeyEvents stopPropagation() handler was removed with the editor, so the DOM-level Escape continued propagating to parent elements. Fixed by deferring teardown via requestAnimationFrame — the Scope handler returns true (consuming the event) while the editor’s scope is still on the stack. Also added a _destroying flag to embeddable-editor.ts to prevent the blur event listener from double-popping the keymap scope when destroy() is already handling cleanup. (#112)
    • Plugin: src/vim/textarea-vim-manager.ts (handleEscapeAndRedispatch — deferred teardown via requestAnimationFrame)
    • Plugin: src/editors/embeddable-editor.ts (_destroying flag, blur handler guard)
  • Escape exit from table cell editor leaks to parent scope — same scope-pop-mid-handler vulnerability as the textarea vim overlay. The onEscape callback in table-nav-controller.ts called exitCellEdit() → closeCellEditor() → editor.destroy() → popKeymapScope() synchronously inside the Scope handler. Fixed with the same deferred-teardown pattern. (#112)
    • Plugin: src/vim/table-nav-controller.ts (onEscape callback — deferred exitCellEdit via requestAnimationFrame)

Tests

  • 1 e2e test in test/specs/textarea-vim.e2e.ts (issue #112): Escape exit does not leak Escape keydown to parent modal container DOM and does not change active leaf — verifies zero Escape events propagate to modal.containerEl, modal stays open, leaf ID unchanged

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated embedded editor Escape handler description with deferred teardown pattern, _destroying guard, and scope-pop-mid-handler fix
  • CONTRIBUTING.md: Updated embeddable-editor.ts description with _destroying guard; updated textarea-vim-manager.ts description with deferred teardown; updated table-nav-controller.ts description with deferred onEscape
  • AGENTS.md: Updated dual-vim architecture section with deferred teardown and _destroying guard

[0.100.0] - 2026-08-07

Fixed

  • :snippet visual selection not captured — running :snippet <name> from visual mode now correctly wraps the selected text. Previously, $TM_SELECTED_TEXT / $VISUAL resolved to empty and the snippet was inserted at the cursor instead of replacing the selection. Root cause: vim’s ex-command dispatcher calls exitVisualMode() before the :snippet handler runs, collapsing the CM6 selection. Fixed by reading the visual selection from vim’s '<'/'>' marks (which survive exitVisualMode) via the cm adapter parameter, extracting the text with cm.getRange(), and overriding ctx.selectedText before preprocessing. Visual line mode (V) normalizes the start to ch: 0 and extends the end to full line length. (Discussion #108)
    • Plugin: src/snippets/commands.ts (recoverVisualSelection() — reads '<'/'>' marks + lastSelection.visualMode/visualLine flags; :snippet handler overrides ctx.selectedText and uses mark-derived from/to offsets for the apply() range)

Tests

  • 10 e2e tests in test/specs/spikes/spike-snippet-visual-surround.e2e.ts (Discussion #108): $TM_SELECTED_TEXT wraps word selection via viw, fills tabstop default in link snippet, $VISUAL alias parity, multiline callout wrapping, empty selection regression guard, link snippet structure, Lua fmt() wraps selection, Lua t()/i() bold wrapping, Lua link structure, Lua empty selection placeholder
  • 12 e2e tests in test/specs/spikes/spike-snippet-visual-edge-cases.e2e.ts (Discussion #108): visual line mode (V) wraps full line, multi-line Vjj wraps three lines, charwise v across lines, visual block mode (<C-v>) produces non-empty text, stale lastSelection uses marks (vim gv semantics), bookmark invalidation falls back to empty, picker baseline, normal mode after visual :snippet (documented pre-existing), correct snippet text despite normal mode, v$ end-of-line, single char at EOL, v$ mid-line to end

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated snippet variable limitations section — $TM_SELECTED_TEXT/$VISUAL tab-expand limitation clarified, visual-mode :snippet now works
  • CONTRIBUTING.md: Added commands.ts to snippets codebase structure with visual selection recovery description
  • docs/features/snippets.md: Updated $TM_SELECTED_TEXT/$VISUAL callout to confirm visual-mode :snippet works

[0.99.0] - 2026-08-06

Added

  • set nopcre — Vim-style regular expressions — users can now switch from JavaScript/PCRE regexps to Vim-style regex syntax in search and substitution via set nopcre (vimrc), vim.opt.pcre = false (Lua), or the Settings UI toggle (Settings → Vim Motions → Vim engine → PCRE). The codemirror-vim fork already implemented the full pcre option (regex translation with magic modes, \</\> word boundaries, \zs/\ze, backreference conversion); this change wires it into the plugin’s option tracking, settings UI, and documentation. Default: true (JavaScript regexps, no behavior change for existing users). (#111)
    • Plugin: src/settings.ts (pcre: boolean in VimMotionsSettings, pcre: true in DEFAULT_SETTINGS, toggle in both declarative and imperative settings UI — General page, Vim engine group)
    • Plugin: src/vimrc/loader.ts (pcre added to KNOWN_SET_OPTIONS and KNOWN_CM_VIM_OPTIONS)
    • Plugin: src/main.ts (initialization sync — vim.setOption('pcre', false) when user has disabled PCRE)
    • Plugin: test/unit/known-set-options.test.ts (pcre added to newOptions test array)
  • 37 snippet variables (up from 16 documented) — expanded the snippet variable system to cover the full VSCode snippet specification, plus vim-ecosystem aliases. New variables: $TM_SELECTED_TEXT (wired — was stubbed), $VISUAL (alias for $TM_SELECTED_TEXT, vim convention), $TM_CURRENT_LINE, $TM_CURRENT_WORD, $WORD (alias for $TM_CURRENT_WORD, vim convention), $TM_LINE_NUMBER (1-based), $TM_LINE_INDEX (0-based), $CLIPBOARD (wired via cache-ahead pattern — was stubbed), $RELATIVE_FILEPATH, $WORKSPACE_NAME, $WORKSPACE_FOLDER, $CURSOR_INDEX, $CURSOR_NUMBER, $CURRENT_MILLISECOND, $CURRENT_MILLISECONDS_UNIX, $CURRENT_TIMEZONE_NAME, plus previously undocumented $CURRENT_YEAR_SHORT, $CURRENT_MONTH_NAME_SHORT, $CURRENT_DAY_NAME_SHORT, $CURRENT_SECONDS_UNIX, $CURRENT_TIMEZONE_OFFSET. $CLIPBOARD uses a cache-ahead pattern (refreshed on window focus and visibilitychange) to avoid making the synchronous snippet pipeline async. On mobile, $CLIPBOARD resolves to empty due to browser clipboard API restrictions. $TM_SELECTED_TEXT / $VISUAL resolve to the editor selection at expansion time; in tab-expand mode, selection is not available (tab expansion requires an empty selection). (#110)
    • Plugin: src/snippets/types.ts (PreprocessContext — added currentLine, currentWord, lineNumber, lineIndex, workspaceName fields)
    • Plugin: src/snippets/variables.ts (added 15 new variable entries including VISUAL, WORD, TM_CURRENT_LINE, TM_CURRENT_WORD, TM_LINE_NUMBER, TM_LINE_INDEX, RELATIVE_FILEPATH, WORKSPACE_NAME, WORKSPACE_FOLDER, CURSOR_INDEX, CURSOR_NUMBER, CURRENT_MILLISECOND, CURRENT_MILLISECONDS_UNIX, CURRENT_TIMEZONE_NAME; added pad3() and getTimezoneName() helpers)
    • Plugin: src/main.ts (_clipboardCache field, refreshClipboardCache() method, clipboard cache listeners on window focus + visibilitychange + initial population; getSnippetPreprocessContext() rewritten to populate all fields from the active editor including selection, current line/word, line number, and workspace name)

Fixed

  • EasyMotion operator-pending inclusivity — EasyMotion motions (f, t, e, s, ge, E, gE) now correctly include the target character in operator-pending mode, matching native Vim semantics. Previously, all EasyMotion motions were registered with empty motionArgs, so the fork treated them as exclusive — y<leader><leader>fk{label} excluded the target k from the yank. Visual mode was unaffected (it extends the selection directly without consulting the inclusive flag). Backward motions (F, T) remain exclusive, matching native Vim. (#109)
    • Plugin: src/easymotion/register.ts (EasyMotionDef interface — added motionArgs?: Record<string, unknown>; EASYMOTION_DEFS — added motionArgs: { inclusive: true } to 8 of 17 defs matching Vim’s native inclusivity; registration loop — passes def.motionArgs ?? {} to mapCommand)
  • Cursor stuck below YAML frontmatter in Live Preview with “Properties in document: Source” — k, gk, and <Up> could not move into the frontmatter region when the editor was in Live Preview mode and Obsidian’s “Properties in document” setting was set to “Source”. In this configuration, the .metadata-container DOM element exists but is hidden (display: none). The fork’s focusBefore callback found the hidden element via querySelector, focused it (no visible effect), and moveByLines/moveByDisplayLines returned the original cursor position — leaving the cursor stuck. Fixed by adding a setPropertiesSource(fn: () => boolean) API to the fork, parallel to setLivePreviewField. When the callback returns true, the frontmatter interception block is skipped entirely and the cursor moves through raw frontmatter text normally. The plugin passes () => getVaultConfig(app, 'propertiesInDocument') === 'source', evaluated per cursor movement so runtime setting changes take effect immediately. (#77)
    • Fork: ~/Repos/codemirror-vim/src/cm_adapter.ts (setPropertiesSource API, _propertiesSourceFn gate in findPosV)
    • Fork: ~/Repos/codemirror-vim/src/index.ts (export setPropertiesSource)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added setPropertiesSource API section, updated “Properties navigation” section with two-level gate)
    • Plugin: src/vim/bundled-vim.ts (createBundledVimExtension accepts isPropertiesSource callback, calls setPropertiesSource)
    • Plugin: src/main.ts (passes propertiesInDocument === 'source' callback)
    • Plugin: src/types/codemirror-vim.d.ts (added setPropertiesSource type declaration)
  • Escape in operator-pending mode exits embedded text area editor — pressing d then Escape in the textarea vim overlay exited the editor instead of clearing the pending operator. The Escape handler checked vim.mode === 'normal' without accounting for operator-pending, surround, partial key sequences, and literal-character-await sub-states. Additionally, the CM6 keymap handler could never run because vim’s eventObservers.keydown called e.preventDefault() before CM6 keymaps processed the event. Fixed by moving Escape handling to an Obsidian Scope.register handler (fires before vim’s observer) with a new isVimIdle() check covering all compound-command sub-states. (#112)
    • Plugin: src/editors/embeddable-editor.ts (isVimIdle helper, VimIdleState interface, Scope-based Escape handler replacing CM6 keymap handler)
  • Keydown events leak from embedded text area editor to parent modals — typing keys (e.g., Space) in insert mode inside the textarea vim overlay propagated keydown events to the parent modal, triggering unintended actions in third-party plugins (e.g., Spaced Repetition). Fixed with a new opt-in isolateKeyEvents option on EmbeddableEditorOptions that stops keydown and keyup propagation via CM6 domEventHandlers. Only enabled for textarea-vim overlays; Oil and table-cell editors are unaffected. (#112)
    • Plugin: src/editors/embeddable-editor.ts (isolateKeyEvents option, domEventHandlers with stopPropagation)
    • Plugin: src/vim/textarea-vim-manager.ts (isolateKeyEvents: true)
  • Unmatched <Space> inserted as text after failed multi-key sequence — pressing an unmapped key after a partial multi-key sequence (e.g., <leader><leader><Space> where no EasyMotion motion matches) inserted a literal space character into the document. Root cause: the fork’s findKey used key.length === 1 to suppress unmatched single-character keys in normal mode, but vimKeyFromEvent converts Space to "<Space>" (7 characters) via the specialKey map, bypassing the guard. The function returned undefined instead of a consuming no-op, letting the keydown propagate to CM6’s text input handler. Fixed by replacing the guard with key.length === 1 || /^<.+>$/.test(key) to match both plain characters and angle-bracket notation keys. (#112)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (findKey — generalized key length guard)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added “Unmatched angle-bracket keys consumed in normal mode” section)

Tests

  • 4 e2e tests in test/specs/easymotion-comprehensive.e2e.ts (issue #109): inclusive f yank includes target character, inclusive e delete includes end-of-word character, exclusive w yank excludes target (regression), visual mode f yank includes target (regression)
  • 1 unit test in test/unit/known-set-options.test.ts (issue #111): pcre option registered in KNOWN_SET_OPTIONS with correct type and settingsKey, default value verified
  • 49 unit tests in test/unit/snippets/variables.test.ts: resolveVariables() coverage for all 37 variables (selection/content, file/path, workspace/cursor, date/time, random), syntax variants ($VAR and ${VAR}), alias parity ($VISUAL = $TM_SELECTED_TEXT, $WORD = $TM_CURRENT_WORD, $RELATIVE_FILEPATH = $TM_FILEPATH, $WORKSPACE_FOLDER = $WORKSPACE_NAME), edge cases (empty fields, unknown variables, tabstop defaults, adjacent variables)
  • 20 e2e tests in test/specs/snippets/snippet-variables-integration.e2e.ts (issue #110): file/path variables against live Welcome.md (5 tests), editor content variables with cursor positioning (4 tests), line number variables 1-based/0-based (3 tests), workspace name and alias (2 tests), cursor index/number constants (2 tests), selection variable resolution via getSnippetPreprocessContext() (3 tests), combined multi-variable expansion (1 test)
  • 3 e2e tests in test/specs/vim-builtin/g-commands.e2e.ts (issue #77): k moves up through source-rendered frontmatter, k navigates through multiple frontmatter properties, gk moves up through source-rendered frontmatter. Tests set propertiesInDocument to 'source' and ensure Live Preview mode, with save/restore of the original setting.
  • 13 unit tests in test/unit/embedded-editor-idle.test.ts (issue #112): isVimIdle coverage for null/undefined, idle normal, insert/visual/replace modes, operator pending, surround state, partial key buffer, expectLiteralNext, multiple sub-states, missing inputState, missing keyBuffer
  • 3 e2e tests in test/specs/textarea-vim.e2e.ts (issue #112): operator-pending Escape does not exit overlay, idle normal Escape exits overlay, insert-mode typing does not leak keydown to parent modal

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated KNOWN_CM_VIM_OPTIONS list in set option scope section to include pcre; added snippet variable limitations section ($CLIPBOARD mobile restriction, $TM_SELECTED_TEXT tab-expand limitation, snip.env deferred, comment variables deferred); updated properties navigation section with “Properties in document: Source” edge case fix and updated test coverage; marked EasyMotion operator-pending inclusivity as fixed; added 4 new known limitations (linewise j/k, motionArgs.forward/clipToLine, EXTRA_DEFS bidirectional motions, easyMotionRepeat operator-pending)
  • CONTRIBUTING.md: Updated variables.ts description in codebase structure; updated bundled-vim.ts description with setPropertiesSource wiring; updated easymotion/register.ts description with per-motion motionArgs for operator-pending inclusivity
  • README.md: Updated Snippets feature line with variable count and vim-ecosystem aliases
  • AGENTS.md: Updated codemirror-vim fork description with setPropertiesSource API
  • docs/features/snippets.md: Expanded variable table from 16 to 37 entries organized into sections (selection/content, file/path, workspace/cursor, date/time, random) with info callout about selection and clipboard behavior
  • docs/features/easymotion.md: Updated operator-pending section with inclusivity semantics; fixed stale dot-repeat note
  • docs/configuration/vimrc.md: Added pcre row to boolean options table
  • docs/configuration/settings.md: Added pcre row to Vim engine settings table
  • docs/configuration/lua-config.md: Added pcre row to vim.opt options table
  • DIFFERENCES.md (fork): Added setPropertiesSource API section, updated “Properties navigation” section with two-level gate
  • KNOWN_LIMITATIONS.md: Updated embedded editor Escape handler description with Scope-based approach and isVimIdle sub-state detection; added key event isolation note
  • CONTRIBUTING.md: Updated embeddable-editor.ts description with isVimIdle helper, Scope-based Escape, and isolateKeyEvents option
  • AGENTS.md: Updated dual-vim architecture section with Scope-based Escape handling for embedded editors
  • DIFFERENCES.md (fork): Added “Unmatched angle-bracket keys consumed in normal mode” section under Behavioral fixes

[0.98.0] - 2026-08-05

Fixed

  • Animated cursor character displaced on lines with tall content — on lines containing tall inline elements (e.g., MathJax with \dfrac), the character rendered beneath the block cursor shifted vertically. Root cause: the renderer’s baseline formula centered the character within the coordsAtPos() rect height, which on some platforms/fonts returns the full line height instead of the per-character height. For a ~80px tall line with ~19px font height, this produced a ~30px downward shift. Fixed by using the actual DOM character bounding rect (Range.getBoundingClientRect() via view.domAtPos()) for baseline calculation, falling back to coordsAtPos() when the DOM rect is unavailable. (#106)
    • Plugin: src/vim/animated-cursor/renderer.ts (BlockCharInfo — added charTop/charHeight fields; drawCursorShape and drawSmearCursor — baseline anchored to DOM char rect when available)
    • Plugin: src/vim/animated-cursor/controller.ts (resolveBlockChar — extracts DOM character bounding rect via Range.getBoundingClientRect())

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Marked animated cursor tall-line character displacement as fixed
  • CONTRIBUTING.md: Updated renderer.ts description with DOM-based baseline calculation
  • AGENTS.md: Updated renderer.ts description in animated cursor codebase structure
  • docs/features/animated-cursor.md: Added tall-line displacement fix to known limitations

[0.97.0] - 2026-08-05

Fixed

  • Priority over Latex Suite and other CM6 extensions — the codemirror-vim fork’s keydown handler no longer depends on plugin load order to fire before other extensions that use Prec.highest. The fork now uses a CM6 eventObservers.keydown (DOM event observer) instead of eventHandlers.keydown — in CM6’s dispatch order, observers run before handlers, guaranteeing vim processes keys first regardless of Prec ordering or community-plugins.json order. Previously, both the fork and Latex Suite registered keydown handlers at Prec.highest, and the first-registered handler won — making key handling dependent on which plugin loaded first. (#107)
    • Fork: ~/Repos/codemirror-vim/src/index.ts (moved keydown from eventHandlers to eventObservers, added setKeyInterceptActive API)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added “Observer-based keydown dispatch” and “setKeyInterceptActive API” sections)
    • Plugin: src/flash/state.ts (setFlashActive and cancelFlash call setKeyInterceptActive)
    • Plugin: src/easymotion/register.ts (createMotionTrigger and createCharMotionTrigger bracket try/finally with setKeyInterceptActive)
    • Plugin: src/ui/hint-mode.ts (waitForHintKey sets setKeyInterceptActive on entry and cleanup)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated Latex Suite interaction section with observer-based keydown dispatch fix
  • AGENTS.md: Updated codemirror-vim fork description with observer-based keydown dispatch and setKeyInterceptActive API
  • docs/guides/ecosystem-compatibility.md: Updated extension priority description with observer-based mechanism

[0.96.0] - 2026-08-04

Fixed

  • Hint mode dropdown menus appear at top-left corner — synthetic click events dispatched by hint mode lacked clientX/clientY coordinates, causing Obsidian’s dropdown menus (vault switcher, context menus, etc.) to position at (0, 0) instead of near the clicked element. Fixed by computing the element’s center from getBoundingClientRect() and passing coordinates to all MouseEvent and PointerEvent dispatches. Also replaced el.click() with a coordinate-aware MouseEvent dispatch. (#104)
    • Plugin: src/ui/hint-mode.ts (getElementCenter helper, coordinate injection in hintActivate — openInNewPane MouseEvent, normal click PointerEvents, and el.click() replacement)

Added

  • Hint mode right-click (context menu) action — new gf binding in non-editor views and Shift+label modifier in editor context to open the right-click context menu on any hint target. Dispatches a contextmenu MouseEvent with proper coordinates from getElementCenter(). Also available as :hintcontextmenu (:hintco) ex command and vim-motions:hint-context-menu Obsidian command. Shift key normalization in waitForHintKey() ensures Shift-held characters match lowercase labels correctly. (#104)
    • Plugin: src/ui/hint-mode.ts (hintContextMenu action, contextMenu in createHintAction/createHintActions, shiftKey in HintResult, Shift→contextMenu upgrade in action selection, e.key.toLowerCase() normalization when Shift held), src/workspace/global-defaults.ts (gf binding, contextMenu in hintActions parameter type), src/main.ts (:hintcontextmenu ex command, vim-motions:hint-context-menu Obsidian command, contextMenu in hintActions type)

Tests

  • 5 e2e tests in test/specs/hint-mode.e2e.ts (issue #104): gf from graph view shows hint overlay, gf label dispatches contextmenu event with non-zero coordinates, Shift+label dispatches contextmenu in editor, Shift+label matches lowercase labels (case-sensitivity regression), hint-context-menu Obsidian command registered

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated hint mode actions with context menu action, coordinate fix, and Shift modifier
  • AGENTS.md: Updated hint-mode.ts description with getElementCenter, hintContextMenu, and Shift modifier
  • CONTRIBUTING.md: Updated hint-mode.ts description
  • README.md: Updated Vimium-style hints feature line with gf
  • docs/features/hint-mode.md: Added gf to non-editor keybinding table, Shift modifier to editor context, vim-motions:hint-context-menu to commands
  • docs/reference/keybindings.md: Added gf to non-editor view bindings table
  • docs/features/ex-commands.md: Added :hintcontextmenu to hint ex commands table

[0.95.0] - 2026-08-04

Fixed

  • Linewise visual select highlighting not visible inside callouts — in visual-line mode (V), callout content did not show the selection highlight in two scenarios: (1) When the callout was collapsed as a widget (cm-embed-block cm-callout), the cm-vim-linewise-widget-selection background was overridden by the callout’s own styling. (2) When the cursor was inside the callout (unfolded as .cm-line elements with HyperMD-quote classes), Obsidian’s .HyperMD-quote { background-color: var(--blockquote-background-color) } rule overrode the cm-vim-linewise-selection background due to CSS cascade ordering. Fixed by increasing CSS specificity of the selection rules to (0,5,0) via .cm-editor .cm-scroller .cm-content ancestor chain, outranking Obsidian’s (0,4,0) blockquote rule without using !important. (#103)
    • Plugin: styles.css (increased specificity on .cm-vim-linewise-selection and .cm-vim-linewise-widget-selection rules)
  • Hint mode does not label the vault switcher — the vault switcher button (.workspace-drawer-vault-switcher) in the left sidebar was not discoverable by hint mode because its CSS class was not in the OBSIDIAN_SELECTORS list. The element is a plain <div> without button semantics (role="button", <button> tag, etc.), so it was not matched by any standard or Obsidian-specific selector. Fixed by adding .workspace-drawer-vault-switcher to OBSIDIAN_SELECTORS. (#104)
    • Plugin: src/ui/hint-mode.ts (added .workspace-drawer-vault-switcher to OBSIDIAN_SELECTORS)
  • Animated cursor displaced rightward at end-of-line in visual mode — with animated cursor enabled, the block cursor rendered one character past the last visible character when visual selection reached the end of a line. Root cause: refreshTarget() stepped back from sel.head in forward selections to render the cursor on the last selected character, but the ch !== '\n' guard prevented the step-back when sel.head pointed to a newline (end of line). Fixed by replacing the character-based guard with a line-boundary guard (pos > line.from), which correctly handles end-of-line, empty lines, and document end. (#105)
    • Plugin: src/vim/animated-cursor/controller.ts (refreshTarget — line-boundary guard replacing character guard)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated visual-line widget highlight section with callout CSS specificity fix; added vault switcher to hint mode target list; added animated cursor EoL visual mode fix

[0.94.0] - 2026-08-01

Fixed

  • Gutter settings ignored when set via vimrc or Lua — set nonumber, set signcolumn=no, vim.opt.number = false, and other gutter-related settings (number, relativenumber, numberwidth, linenumbermode, cursorline, cursorlineopt, signcolumn, statuscolumn, foldcolumn) had no effect when configured via .obsidian.vimrc or .obsidian.init.lua. Root cause: vimrc/Lua overrides were in-memory only and never persisted, but gutter CM6 extensions are created at startup from persisted values. Fixed with a configOverrides persistence system: after vimrc/Lua loading, override values are captured and persisted in data.json. On next startup, configOverrides are merged on top of base settings before CM6 extensions are created, so gutters use the correct values from the start. Also added gutter reconfiguration calls to reloadFeatures() for in-session changes. (#101)
    • Plugin: src/main.ts (loadSettings — configOverrides extraction and merge; captureConfigOverrides — new shared capture method; saveSettings — persist configOverrides; reloadFeatures — gutter reconfiguration; softReloadVimrc — clear stale overrides and re-capture; clearSettingOverride — new helper)
    • Plugin: src/settings.ts (replaced vimrcOverrides?.delete with clearSettingOverride across all onChange handlers — covers both declarative and imperative paths, fixes missing luaOverrides deletion)
  • preVimrcSettings shallow copy — nested objects (cursorShapes, modePrompts, pickerKeymap) shared references with this.settings, causing saveSettings() to accidentally persist overridden cursor shapes. Fixed with deep copy.
    • Plugin: src/main.ts (line 677 — deep copy nested objects in preVimrcSettings snapshot)
  • Clipboard/textwidth falsely shown as “Set by vimrc” — the initial settings restoration at startup called onSettingOverride() for clipboard and textwidth, writing to vimrcOverrides even without a vimrc file. Fixed by using direct side-effect calls.
    • Plugin: src/main.ts (replaced onSettingOverride calls with direct setClipboardOption/setTextwidth calls)
  • Oil explorer loses focus after committing staged changes — after making changes in Oil (e.g., deleting a file) and committing with :w, the Oil editor lost focus when the confirmation dialog was confirmed or dismissed. Two bugs: (1) OilConfirmModal.onClose() never resolved the promise when the user pressed Esc to dismiss the modal, causing commit() to hang permanently. Fixed by adding a resolved guard — onClose() resolves false when no button was clicked. (2) After the confirmation dialog closed (via Confirm, Cancel, or Esc), focus was never returned to the Oil editor. Fixed by calling view.focusEditor() on both the cancel and commit paths. (#100)
    • Plugin: src/oil/manager.ts (OilConfirmModal — resolved guard flag, onClose resolves on Esc dismissal; commit — view.focusEditor() after confirm and cancel paths)
  • :sort cursor positioning — :sort (and ranged :2,3sort) now positions the cursor at the first line of the sorted range, matching Neovim. Previously the cursor stayed at line 0 regardless of the sort range.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (exCommands.sort — cm.setCursor after replaceRange)
  • CTRL-V $ d cursor overshoot — after a block visual delete to end-of-line (CTRL-V jj $ d), the cursor column is now clamped to the remaining line length. Previously the cursor could land past the last character on shortened lines.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (operators.delete — block visual cursor clamping)
  • Hint mode: modifier keydown event propagates to Obsidian handlers — pressing Ctrl alone during hint mode could trigger Obsidian’s own key handlers because the modifier-only early return in waitForHintKey() did not call preventDefault() or stopPropagation(). The event leaked through to Obsidian’s hotkey system via bubble-phase listeners, potentially causing side effects depending on the user’s Obsidian configuration. Fixed by adding e.preventDefault() and e.stopPropagation() before the early return for modifier-only keys. (#98)
    • Plugin: src/ui/hint-mode.ts (waitForHintKey handler — preventDefault+stopPropagation on modifier-only keydown)
  • Hint mode: count prefix (2F) focus restoration races with async navigation — when using 2F on an internal link target, hintActivate() fired navigateWithJump() without awaiting it (via void), then setActiveLeaf(originalLeaf) ran synchronously. The async openLinkText() inside navigateWithJump could resolve after the focus restoration and steal focus back to the new tab — a race condition that manifested on slower machines or with heavier vaults. Similarly, duplicateLeaf() was fire-and-forgotten. Fixed by making hintActivate async and awaiting both navigateWithJump() and duplicateLeaf(). The createHintAction callback now awaits the action result before restoring focus, making the behavior deterministic. (#98)
    • Plugin: src/ui/hint-mode.ts (hintActivate — async, await navigateWithJump, await duplicateLeaf; hintOpenNew — returns Promise<boolean>; createHintAction — async callback, await action(), Promise.resolve() wrappers for sync actions)

Added

  • 27 new vimrc/Lua configurable options — the following settings were previously only configurable via the Settings UI and are now available via :set in vimrc and vim.opt in Lua: subword, picker, pickerleadermappings, pickermatcher, pickeromnisearch, pickertasks, pickerdataview, ripgrep, ripgreppath, ripgrepargs, grepmode, oil, oilhiddenfiles, oilconfirmdeletethreshold, oilsort, hinthotkey, undotreeposition, undotreeautoopen, imswitching, impreset, imbinarypath, imobtainargs, imswitchargs, imdefaultnormal, imrestorebehavior, imdefaultinsert. All options work identically across Settings UI, vimrc, and Lua.
    • Plugin: src/vimrc/loader.ts (27 new KNOWN_SET_OPTIONS entries)
  • Runtime invariant system — invariant() (always-on, type-narrowing) and devAssert() (dev-only, stripped from production) helpers in src/util/invariant.ts. 21 invariants placed across 9 source files protecting mode transitions, dual-vim architecture, settings resolution, Lua engine lifecycle, extension cleanup, cursor state, and cell editor singleton. Violations are logged to console, rate-limited via Notice, and inspectable via the :violations ex command. __DEV__ build-time flag via esbuild define enables dev-only checks in development/watch builds and strips them from production.
    • Plugin: src/util/invariant.ts (new — invariant, devAssert, getViolations, clearViolations), src/types/globals.ts (new — __DEV__ global type declaration), esbuild.config.mjs (define option), vitest.config.ts (define option)
  • :violations ex command — displays accumulated invariant violations with timestamps. :violations! clears the violation log.
    • Plugin: src/workspace/commands.ts (:violations and :violations! registration)

Tests

  • 57 new unit tests across 5 new test files:
    • test/unit/invariant.test.ts (12 tests): invariant/devAssert helpers, violation cap, rate limiting, stack traces, shallow copy
    • test/unit/mode-tracker.test.ts (11 tests): getDialogPrefix, resolveMode logic
    • test/unit/dual-vim.test.ts (7 tests): bundled-vim lifecycle, bridge install/uninstall, invariant trigger
    • test/unit/settings-resolution.test.ts (16 tests): DEFAULT_SETTINGS completeness, settings merge, configMode migration, signcolumn migration, idempotency
    • test/unit/lua/lifecycle.test.ts (8 tests): sandboxed state creation/destruction, instruction guard, coroutine runner lifecycle
  • 3 expanded tests in test/unit/animated-cursor.test.ts: manager register/deregister, destroy clears all, MAX_CONTROLLERS warning
  • 70 new Neovim golden test cases (490 → 560): operator+motion combos (+28), visual mode operations (+15), insert mode operations (+12), ex command operations (+15)
  • 2 Neovim deviations closed (22 → 20): :2,3sort cursor positioning, CTRL-V $ delete to EOL cursor overshoot
  • 30 unit tests in test/unit/known-set-options.test.ts: KNOWN_SET_OPTIONS coverage guard (every non-excluded settings key has an entry, excluded keys list has no stale entries, no settingsKey points to non-existent setting), 27 new option entry validations (type, settingsKey, validValues)
  • 6 e2e tests in test/specs/gutter-vimrc-lua.e2e.ts: gutter settings via Lua config (enable/disable line numbers, enable/disable sign column, disable all gutter elements, hybrid line numbers)
  • 4 e2e tests in test/specs/oil-poc.e2e.ts (issue #100): oil retains focus after no-op commit, oil retains focus after confirmed destructive commit, oil retains focus after cancelled destructive commit, oil retains focus after Esc-dismissing the confirm modal
  • 3 e2e tests in test/specs/hint-mode.e2e.ts (issue #98): F on wikilink opens in new tab via command, openNew(2) on wikilink keeps focus on original leaf after first hint, Ctrl keydown stopped from propagating during hint mode

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Marked gutter vimrc/Lua reconfiguration as fixed; marked preVimrcSettings shallow copy as fixed; marked clipboard/textwidth false override as fixed; updated set option scope section with configOverrides persistence; marked Oil focus loss after commit as fixed
  • docs/features/oil-explorer.md: Added focus retention after commit note
  • AGENTS.md: Updated hint mode page ownership with hinthotkey
  • README.md: Updated vimrc configurable settings count
  • CONTRIBUTING.md: Added configOverrides persistence and clearSettingOverride helper to conventions; updated vimrc loader description
  • docs/configuration/vimrc.md: Added 27 new options to vimrc tables; updated override behavior section with configOverrides persistence and gutter restart note
  • docs/configuration/lua-config.md: Added 27 new options to vim.opt table
  • KNOWN_LIMITATIONS.md: Marked :sort cursor and CTRL-V $ cursor deviations as fixed
  • CONTRIBUTING.md: Added invariant system to codebase structure, updated testing instructions for build:dev
  • AGENTS.md: Updated manual testing instructions for build:dev, added :violations command, updated golden test count and deviation count
  • README.md: Added npm run test:unit to development commands
  • DIFFERENCES.md (fork): Added :sort cursor positioning and block visual delete cursor clamping sections
  • docs/features/ex-commands.md: Added :violations and :violations! ex commands
  • KNOWN_LIMITATIONS.md: Updated hint mode modifier key fix with stopPropagation; updated count prefix focus fix with async hintActivate
  • AGENTS.md: Updated hint-mode.ts description with async hintActivate and modifier stopPropagation

[0.93.0] - 2026-07-31

Fixed

  • Hint mode: pressing Ctrl/Shift/Alt/Meta alone clears labels — pressing any modifier key alone during hint mode dismissed the overlay. Root cause: waitForHintKey() in hint-mode.ts treated modifier-only keydown events (where e.key is "Control", "Shift", etc.) as unmatched first characters, triggering cleanup. The global key handler (global-key-handler.ts:228-234) already filtered modifier-only keys correctly. Fixed by adding the same guard at the top of waitForHintKey()’s handler. (#98)
    • Plugin: src/ui/hint-mode.ts (waitForHintKey handler — modifier-key guard before preventDefault)
  • Hint mode: count prefix (2F) shifts focus to new tab immediately — when using a count prefix (e.g., 2F) in non-editor context, the first hint activation shifted focus to the newly opened tab, causing the second round of hints to appear on the wrong tab. Root cause: hintActivate() with openInNewPane=true calls navigateWithJump() or duplicateLeaf(), both of which focus the new leaf. The next run(count-1) then showed hints on the new tab. Fixed by saving the original active leaf before waitForHintKey when count > 1, and restoring focus to it after each activation before scheduling the next round. (#98)
    • Plugin: src/ui/hint-mode.ts (createHintAction — originalLeaf capture + setActiveLeaf restore before recursive run)
  • Hint mode: <leader><leader>h ignores count prefix — the hintMode action defined via defineAction did not accept ActionArgs, so actionArgs.repeat (the count from vim’s input state) was never passed to activate(). Count prefix only worked in non-editor context (via global key handler). Fixed by accepting (_cm, actionArgs) and passing actionArgs.repeat. (#98)
    • Plugin: src/main.ts (defineAction('hintMode') — accept actionArgs, pass repeat to activate)

Tests

  • 6 e2e tests in test/specs/hint-mode.e2e.ts (issue #98): Ctrl alone keeps labels, Shift alone keeps labels, Alt alone keeps labels, Meta alone keeps labels, Ctrl then label char still narrows labels, 2F keeps focus on original graph view leaf

[0.92.1] - 2026-07-31

Fixed

  • Ctrl hotkeys broken on active tab after closing Oil explorer — after closing Oil (via q, :q, or closeOil()), Ctrl-based hotkeys (<C-d>, <C-f>, <C-b>, etc.) stopped working on the restored file until the user switched to another tab and back. Root cause: OilView.onClose() called removeChild(editor) which triggers unload() but not destroy(). The popKeymapScope call that removes the Oil-specific Obsidian Scope (with Ctrl+T/S/H/L/C handlers) lives in destroy(), so the scope remained pushed on the keymap stack after Oil was gone — intercepting Ctrl keys and silently consuming them. Fixed by calling this.editor.destroy() before removeChild() in onClose(). (#93)
    • Plugin: src/oil/oil-view.ts (onClose — explicit destroy() before removeChild)

Tests

  • 2 e2e tests in test/specs/oil-poc.e2e.ts: Ctrl keys work after closing Oil via closeOil() (scope cleanup regression), Ctrl keys work after opening and closing Oil multiple times (scope stack leak detection)

[0.92.0] - 2026-07-31

Fixed

  • Oil <C-t>/<C-s>/<C-h> keybindings intercepted by Obsidian default hotkeys — pressing <C-t> in Oil opened an empty Obsidian tab instead of the file under cursor. <C-s> triggered Obsidian’s save and <C-h> triggered search & replace. Root cause: Obsidian’s default hotkeys (Ctrl+T = new tab, Ctrl+S = save, Ctrl+H = search & replace) fire at the Electron level before the embeddable editor’s vim key handler receives the event. The vim mapping (vim.map('<C-t>', ':oilopentab<CR>', 'normal')) never executed. Fixed by registering Ctrl+T, Ctrl+S, Ctrl+H, Ctrl+L, and Ctrl+C on the embeddable editor’s Obsidian Scope (the same mechanism that already intercepts Mod+Enter). Scope-registered keys fire before Obsidian’s default hotkeys. Navigation keys (<C-t>, <C-s>, <C-h>) blur the editor before calling the manager action so the setActiveLeaf guard in the embeddable editor allows the new leaf through. Non-navigation keys (<C-l> refresh, <C-c> close) call the manager directly. The ex commands (:oilopent, :oilopensv, :oilopensh, :oilrefresh, :oilclose) continue to work via vim.defineEx for users who prefer typing them. (#93)
    • Plugin: src/editors/embeddable-editor.ts (registerScopeKey() method on EmbeddableMarkdownEditor interface and ConcreteEmbeddableEditor class — delegates to the internal Obsidian Scope), src/oil/oil-view.ts (registerOilScopeKeys() — registers 5 Ctrl-key combos on the editor scope with blur-before-navigate for cross-leaf actions)

Tests

  • 1 e2e test in test/specs/oil-poc.e2e.ts: <C-t> opens file in new tab and focuses it (regression test — verifies active file is the target, active view type is markdown, Oil view still exists, leaf count increased)

[0.91.0] - 2026-07-30

Fixed

  • Which-key popup disappears quickly in non-editor views — in non-editor views (reading view, graph, canvas, etc.), the which-key popup appeared and vanished after ~500ms instead of staying visible until the user completed the key sequence. Root cause: the global key handler’s 1000ms SEQUENCE_TIMEOUT fired resetSequence() unconditionally, dismissing the popup even when partial completions existed. In editor mode, the which-key overlay stays open until the command completes (driven by vim-keypress/vim-command-done events, not a fixed timer). Fixed by checking for partial matches when the timeout fires — if the current key buffer has pending completions in the registry, the timeout restarts instead of resetting. The popup now stays alive until the user completes or abandons the sequence. (#97)

    • Plugin: src/workspace/global-key-handler.ts (startTimeout — partial-match check before resetSequence)
  • gt always navigates to first tab instead of next tab in non-editor views — pressing gt without a count prefix in non-editor views (graph, canvas, reading view) always jumped to the first tab instead of cycling to the next tab. Root cause: dispatch() in the global key handler used this.count || 1, making count 0 (no count typed) indistinguishable from count 1 (user typed 1gt). The gt handler’s if (count > 0) always triggered gotoNthTab(app, 1). Fixed by passing this.count directly to builtin handlers, letting each handler decide its own default. The gt handler already had the correct branching (count > 0 → nth tab, else → next tab). Other handlers (j/k scroll, hint actions) apply count || 1 locally. (#97)

    • Plugin: src/workspace/global-key-handler.ts (dispatch — raw this.count for builtin, this.count || 1 for obcommand repeat), src/workspace/global-defaults.ts (local count || 1 in scroll/hint handlers)
  • Ngt (count + gt) ignored count in editor views — pressing 2gt or 3gt in an editor view always went to the next tab instead of the Nth tab. Root cause: the editor-mode gt was mapped to workspace:next-tab via createCommandAction, which ignores actionArgs.repeat entirely. The count-aware gotoTab action was only mapped to g<C-t>. Fixed by replacing the gt mapping with a new gtAction that uses actionArgs.repeatIsExplicit to distinguish “no count typed” (next tab) from “count N typed” (go to tab N). (#97)

    • Plugin: src/workspace/navigation.ts (gtAction — repeatIsExplicit check, gotoNthTab for explicit count, workspace:next-tab for no count)
  • gotoNthTab counted sidebar leaves in tab numbering — Ngt and g<C-t> counted all workspace leaves (including sidebar panes) when determining the Nth tab. 3gt could navigate to a sidebar pane instead of the 3rd editor tab. Fixed by filtering leaves with leaf.getRoot() === app.workspace.rootSplit to only count main editor area leaves, matching the existing pattern in src/lua/loader.ts. (#97)

    • Plugin: src/workspace/global-defaults.ts (gotoNthTab — rootSplit filter), src/workspace/navigation.ts (createGotoTabAction — rootSplit filter)
  • Oil editor degraded when opened from non-editor context — opening Oil from an empty pane, settings view, graph view, or any non-markdown context produced a broken editor: keybindings (g?, <CR>, q) didn’t work, which-key popup didn’t appear, and the cursor could move through concealed icon ranges character by character. Two root causes: (1) In embeddable-editor.ts, the builtinVimOn closure variable captured isVimEnabled(app) which returns true when the bundled fork is active — making the guard !builtinVimOn && isBundledVimActive() always false and the explicit vim extension push dead code. The embedded editor relied entirely on Obsidian’s registerEditorExtension() injection to receive vim, which could fail on leaves that had never hosted a MarkdownView. Fixed by removing the dead guard and adding a post-construction ensureVimExtension() safety net that checks for vim presence via getCM() and appends it via StateEffect.appendConfig only if absent. (2) In manager.ts, openOil() called getLeaf(false) which reuses the current leaf — when that leaf was a non-editor view (empty pane, settings), it lacked initialized editor infrastructure. Fixed by priming the leaf with a temporary markdown view state (setViewState({ type: 'markdown' })) before switching to the Oil view type when no MarkdownView is active.

    • Plugin: src/editors/embeddable-editor.ts (removed builtinVimOn closure, removed dead vim push from buildLocalExtensions, added ensureVimExtension() with getCM check + StateEffect.appendConfig fallback, replaced isVimEnabled import with isBuiltinVimEnabled + getCM), src/oil/manager.ts (openOil — leaf priming with setViewState({ type: 'markdown' }) when no active MarkdownView)
  • Cannot open files/folders from Oil explorer at vault root — after the v0.90.0 fix, pressing <CR> on any file or folder in the Oil explorer did nothing. Root cause: discoverAndMergeHidden() called cache.loadDirectory() three times during a single refresh cycle, causing buffer entry IDs to become out of sync with the cache. Entry lookup by ID returned undefined, so openEntryAtCursor() silently aborted. Fixed by passing the expected buffer content from the initial render as a parameter to discoverAndMergeHidden(), eliminating the redundant renderDirectoryToBuffer() call that triggered the third cache.loadDirectory(). The cache is now updated exactly once per merge. Confirmed by spike unit test demonstrating ID desync (buffer IDs [1,2] vs cache IDs [6,7]). (#93)

    • Plugin: src/oil/manager.ts (discoverAndMergeHidden — accepts expectedContent parameter, removed redundant renderDirectoryToBuffer call), src/oil/oil-view.ts (callers pass rendered content)
  • Oil explorer title bar does not update when navigating directories — after navigating from one directory to another, the tab header continued to show the original directory name. Root cause: setDirectory() and refreshContent() updated this.dirPath but never signaled Obsidian to re-read getDisplayText(). Fixed by adding notifyHeaderChanged() which calls leaf.updateHeader() (Obsidian internal) after dirPath changes, in setDirectory(), refreshContent(), and setState(). (#93)

    • Plugin: src/oil/oil-view.ts (notifyHeaderChanged private method, called from setDirectory, refreshContent, setState)
  • Hidden files toggle (g.) has no effect — pressing g. in Oil to toggle hidden files did nothing. Root cause: this.settings.oilShowHiddenFiles ?? this.showHidden used the nullish coalescing operator (??), but oilShowHiddenFiles is typed as boolean (default false), so ?? never fell through to the runtime toggle this.showHidden. Fixed by replacing the boolean field with a showHiddenOverride: boolean | null (null = use setting) and a getEffectiveShowHidden() helper that prioritizes the override when set. (#93)

    • Plugin: src/oil/manager.ts (showHiddenOverride field, getEffectiveShowHidden() helper, toggleHidden() rewritten)
  • <CR> in Oil opens file in new tab instead of replacing Oil view — pressing Enter on a file in Oil opened it in a new tab, leaving the Oil view in the original tab. In oil.nvim, <CR> (select) opens the file in the same window, replacing the oil buffer. Root cause: navigateWithJump() used openLinkText() which cannot replace a custom view type. Fixed by using leaf.openFile() directly on the Oil leaf via navigateWithJumpFile(), matching the pattern used by closeOil(). (#93)

    • Plugin: src/oil/manager.ts (openEntryAtCursor rewritten to use openFileInLeaf, new openFileInLeaf private method), src/oil/keybindings.ts (oilOpenEntry delegates to manager.openEntryAtCursor())

Added

  • Oil <C-t> open in new tab — new :oilopentab ex command mapped to <C-t>, matching oil.nvim’s default. Opens the file under cursor in a new tab while keeping the Oil view in the current tab.
    • Plugin: src/oil/manager.ts (openEntryAtCursorInNewTab), src/oil/keybindings.ts (mapping + action)
  • Oil <C-s> / <C-h> split open — new :oilopensv and :oilopensh ex commands mapped to <C-s> (vertical split) and <C-h> (horizontal split), matching oil.nvim’s defaults. Opens the file under cursor in a split pane alongside the Oil view.
    • Plugin: src/oil/manager.ts (openEntryAtCursorInSplit), src/oil/keybindings.ts (mappings + actions)
  • Oil <C-c> close — <C-c> now maps to :oilclose, matching oil.nvim’s default close binding. q remains as an additional close key.
    • Plugin: src/oil/keybindings.ts (mapping)
  • Oil gx open in default app — new :oilopenexternal ex command mapped to gx, matching oil.nvim’s default. Opens the file under cursor in the system’s default application via app.openWithDefaultApp().
    • Plugin: src/oil/manager.ts (openEntryExternalAtCursor), src/oil/keybindings.ts (mapping + action)

Tests

  • 13 unit tests in test/unit/global-key-handler.test.ts: dispatch count for builtin actions (count=0, count=1, count=3, count reset after dispatch), dispatch count for obcommand actions (once without count, N times with count), gt tab navigation issue #97 (gt without count → next tab, 3gt → nth tab, 1gt → nth tab), sequence timeout with partial matches (keeps alive, dispatches after restart, resets on no match, no lingering after exact match)
  • 4 unit tests in test/unit/global-defaults.test.ts: gotoNthTab via gt mapping (skips sidebar leaves, first root tab for count=1, no-op when count exceeds tabs, workspace:next-tab for count=0)
  • 11 e2e tests in test/specs/global-nav.e2e.ts (issue #97): editor-mode Ngt (gt without count → next tab not first, 1gt → first, 2gt → second, 3gt → third, 9gt → stays), non-editor-mode Ngt (gt → next, 1gt → first, 2gt → second, 3gt → third, 9gt → stays), sequence timeout updated (partial match keeps sequence alive)
  • 12 unit tests in test/unit/oil-cache-sync.test.ts: cache ID synchronization after render (5 tests), getEffectiveShowHidden override logic (5 tests), renderDirectory at vault root (2 tests)
  • 11 e2e tests in test/specs/oil-poc.e2e.ts: vault root folder navigation (2 tests), title bar update on directory change (2 tests), hidden files toggle (1 test), same-leaf file open (1 test), <C-t> keymap registration (1 test), vertical and horizontal split open (2 tests), gx method registration (1 test), Obsidian reload for split cleanup (1 test)
  • Modal class added to test/unit/__mocks__/obsidian.ts to unblock unit tests importing manager.ts

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Marked Oil non-editor context degradation as fixed; updated vim state per-editor note with ensureVimExtension() safety net
  • CONTRIBUTING.md: Updated embeddable-editor.ts description (ensureVimExtension safety net) and manager.ts description (leaf priming)
  • AGENTS.md: Updated dual-vim architecture section with embedded editor vim injection and safety net
  • docs/features/oil-explorer.md: Added non-editor context opening note
  • KNOWN_LIMITATIONS.md: Added which-key popup timeout fix and gt/Ngt tab navigation fixes
  • docs/features/workspace-navigation.md: Added Ngt count support description and which-key timeout fix note
  • docs/reference/keybindings.md: Already had Ngt row — no change needed
  • CONTRIBUTING.md: Updated global-key-handler.ts and global-defaults.ts descriptions
  • KNOWN_LIMITATIONS.md: Marked Oil cache desync, title bar, and hidden toggle as fixed; added <CR> same-leaf fix; added new keymaps (<C-t>, <C-s>, <C-h>, <C-c>, gx)
  • docs/features/oil-explorer.md: Updated Oil ex commands table with new keymaps
  • docs/features/ex-commands.md: Updated Oil ex commands table with new keymaps
  • docs/reference/keybindings.md: Updated Oil keybindings table with new keymaps
  • CONTRIBUTING.md: Updated Oil keybindings description
  • README.md: Updated Oil feature description with oil.nvim-matching keybindings

[0.90.0] - 2026-07-30

Fixed

  • Note freezes in Reading Mode after closing Oil explorer — closing the Oil explorer view (via q, :q, :wq, or Lua vim.ob.oil.close()) reopened the previous file in Obsidian’s default mode (often Reading/Preview) instead of the mode the user was in when they opened Oil. Root cause: openOil() captured previousFile (path only) but not the editor’s view mode. Fixed by capturing previousViewMode (the MarkdownView state: source mode, live preview, or reading mode) when opening Oil and restoring it via leaf.openFile(file, { state: previousViewMode }) on close. All 4 close paths (keybindings q, ex commands :q/:wq, and Lua API vim.ob.oil.close()) are unified into a single closeOil() method on OilManager. (#93)
    • Plugin: src/oil/oil-view.ts (previousViewMode field, getState/setState extended, getPreviousViewMode getter), src/oil/manager.ts (openOil captures mode via MarkdownView.getState(), new closeOil() shared method with mode restoration), src/oil/keybindings.ts (oilClose delegates to manager.closeOil()), src/workspace/commands.ts (closeOilView delegates to oilManager.closeOil()), src/main.ts (Lua API oilClose delegates to oilMgr.closeOil())
  • Cursor focus lost when switching back to Oil tab — after opening a file from Oil and then switching back to the Oil tab via gT or Obsidian’s tab navigation, the cursor focus was missing. Keystrokes were not captured by the Oil editor until the user clicked with the mouse. Root cause: Oil’s editor focus was set only once in onOpen() and never re-applied when switching back. Fixed by adding a focusEditor() method to OilView and calling it from OilKeybindingManager.onActiveLeafChange() when switching into an Oil view. (#93)
    • Plugin: src/oil/oil-view.ts (focusEditor() public method), src/oil/keybindings.ts (onActiveLeafChange calls view.focusEditor() when switching to Oil)
  • :Oil . opens current file’s directory instead of vault root — running :Oil . opened the directory containing the current active file rather than the vault root. In oil.nvim, . means current working directory, which maps to the vault root in Obsidian. Root cause: the condition if (!dirPath || dirPath === '.' || dirPath === '/') treated . identically to an empty argument. Fixed by separating . and / into their own branch that resolves to vault root (""), while the empty-argument case continues to resolve to the current file’s parent directory. Both the ex command handler (commands.ts) and global ex command handler (global-ex-command.ts) are updated. (#93)
    • Plugin: src/workspace/commands.ts (:Oil ex command path resolution), src/ui/global-ex-command.ts (global ex command path resolution)
  • Hidden files (dotfiles) not shown in Oil explorer — hidden files and folders (e.g., .gitignore, .git/) were not visible in Oil even with “Show hidden files” enabled. Root cause: app.vault.getFiles() and app.vault.getAllFolders() only return Obsidian-indexed files, and Obsidian does not index dotfiles. Fixed by adding a two-pass rendering approach: the initial sync render uses the Vault API (unchanged), then an async second pass discovers hidden entries via app.vault.adapter.list() (which returns all filesystem entries including dotfiles) and merges them into the listing. A race condition guard prevents overwriting user edits during the async merge. Hidden files are currently view-only — CRUD operations on dotfiles may fail because they lack TFile/TFolder objects in the Vault index. (#93)
    • Plugin: src/oil/render.ts (exported getParentPath/isInConfigDir, new discoverHiddenEntries() function), src/oil/manager.ts (new discoverAndMergeHidden() method with race condition guard), src/oil/oil-view.ts (setEditorContent() method, async trigger in onOpen() and refreshContent())
  • Inconsistent behavior when deleting surroundings with doubled symmetric delimiters — ds$ on $$example$$ did nothing instead of deleting the innermost $ pair to produce $example$. Same failure for ds" on ""hi"", cs$ on $$example$$, and other symmetric (same open/close) surround characters when doubled. Root cause: findSurroundingQuotes() in the codemirror-vim fork paired all quote positions sequentially at even/odd indices (i += 2). For $$example$$ with positions [0, 1, 9, 10], this created pairs (0,1) and (9,10) — the two adjacent $$ on each side — leaving the cursor between them with no match. Fixed by replacing the sequential pairing with cursor-expansion: search backward from cursor for the nearest quote character (open), then forward for the next one (close). This correctly handles both doubled delimiters ($$example$$ → finds inner pair (1, 9)) and adjacent pairs ("hello" "world" → finds pair around cursor). (#96)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (findSurroundingQuotes — cursor-expansion algorithm replacing sequential i += 2 pairing)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added “Symmetric surround quote matching” section)
  • Snippet ex commands do not work after vimrc/Lua config reload — :snippet <name> and :snippets ex commands silently stopped working after any reloadFeatures() cycle (triggered by vimrc loading, Lua config loading, or settings changes). Root cause: registerSnippetCommands() was called only in onload(), but reloadFeatures() calls unregisterAll() which replaces all registered ex commands with no-ops — and snippet commands were never re-registered. The Picker-based snippet insertion was unaffected because it uses a separate pickerRegistry not managed by VimRegistration. Fixed by adding registerSnippetCommands() to reloadFeatures(), matching the pattern used by all other feature registrations. (#95)
    • Plugin: src/main.ts (reloadFeatures — added registerSnippetCommands call gated by enableSnippets)
  • Which-key shows EasyMotion commands incorrectly with space leader — EasyMotion commands (prefixed with <leader><leader>) appeared at the wrong level in the which-key popup when using space as the leader key. Two root causes: (1) LeaderRegistry.addBinding() stripped the leader prefix using the raw leader key (" "), but onKeyPressLeaderOnly() compared against normalized keys ("<Space>" from vim-keypress events). The stored binding keys (" f") never matched the normalized drill-down prefix ("<Space>"). Similarly, addGroupLabel() stored the group label key in raw format, causing getRelativeGroupLabels() lookups to miss. Fixed by normalizing both lhs and prefix via normalizeVimKey() at storage time in addBinding() and addGroupLabel(). (2) In grouped mode, buildNextKeyEntries() called isSpecialKey() to filter out non-typeable keys like <CR>, <Left>, etc. — but <Space> was also treated as special, causing all EasyMotion bindings (whose first key after leader-stripping is <Space>) to be silently dropped from the grouping. Fixed by exempting <Space> from the special key check. (#94)
    • Plugin: src/ui/which-key.ts (LeaderRegistry.addBinding — normalize lhs and leader before stripping; LeaderRegistry.addGroupLabel — normalize prefix before storing; isSpecialKey — exempt <Space> from special key filtering)

Tests

  • 6 fork tests in ~/Repos/codemirror-vim/test/vim_test.js: ds_doubled_dollar_deletes_inner, ds_doubled_quote_deletes_inner, cs_doubled_dollar_changes_inner, ds_single_dollar_pair, ds_adjacent_dollar_pairs, ds_dollar_cursor_on_delimiter
  • 5 e2e tests in test/specs/surround.e2e.ts (doubled symmetric delimiters — #96): ds$ on $$example$$ in Live Preview, ds" on ""hi"", cs$ on $$example$$, ds$ on single $hello$, ds$ on adjacent $hello$ $world$
  • 28 unit tests in test/unit/which-key.test.ts: LeaderRegistry normalization (raw space leader, pre-normalized leader, format consistency, non-leader rejection, bare-leader rejection, deduplication, backslash leader, comma leader), group label normalization (raw vs normalized prefix, cross-format consistency), clearBuiltinBindings with normalized keys, double-leader drill-down (issue #94 scenario — EasyMotion bindings filterable by <Space> prefix, single-leader bindings excluded), isSpecialKey (<Space> exempt, other angle-bracket keys special, plain keys not special)
  • 2 e2e tests unskipped in test/specs/snippets/snippet-variables.e2e.ts: :snippet command expands by name, :snippets opens picker
  • 1 e2e test in test/specs/settings-reload.e2e.ts: snippet ex commands survive reloadFeatures() (regression test for #95)
  • 17 unit tests in test/unit/oil-render.test.ts: getParentPath (4 tests), isInConfigDir (4 tests), discoverHiddenEntries (9 tests — dotfiles, dot-folders, index exclusion, config dir exclusion, non-dotfile exclusion, adapter.list failure graceful fallback, nested paths, mixed entries, empty results)
  • 5 e2e tests in test/specs/oil-poc.e2e.ts (Oil explorer #93): :Oil . opens vault root, :Oil / opens vault root, closing oil restores source mode, closing oil restores live preview mode, closeOil() restores previous file

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added surround doubled symmetric delimiter fix
  • AGENTS.md: Updated fork test count (1882)
  • DIFFERENCES.md (fork): Added “Symmetric surround quote matching” section
  • docs/features/surround.md: Added doubled delimiter behavior note
  • KNOWN_LIMITATIONS.md: Added hidden files view-only limitation to Oil section; marked Reading Mode freeze, focus loss, and :Oil . path resolution as fixed
  • CONTRIBUTING.md: Updated Oil codebase structure descriptions (oil-view.ts, manager.ts, render.ts)
  • docs/features/oil-explorer.md: Updated with mode restoration on close, focus restoration on tab switch, :Oil ./:Oil / path semantics, hidden files via adapter API, view-only dotfile limitation
  • docs/features/ex-commands.md: Updated :Oil argument description
  • docs/reference/keybindings.md: Updated :Oil description with .// path support
  • KNOWN_LIMITATIONS.md: Marked ex command snippet expansion as fixed; added which-key EasyMotion double-leader fix to which-key overlay section
  • docs/features/snippets.md: Updated ex command trigger description noting reload survival
  • docs/configuration/which-key.md: Added note about double-leader prefix grouping for EasyMotion

[0.89.0] - 2026-07-30

Fixed

  • Insert-mode surround dot-repeat — . after i<C-G>s{char}text<Esc> now replays the full surround + typed text. Previously, dot-repeat replayed only the typed text without delimiters. The fork stores _surroundInsertChar and _surroundInsertNewline on lastInsertModeChanges. During replay, replaySurroundAwareInsert (inside repeatLastEdit) strips the delimiter entry from changes[0], inserts pair.open, replays typed text via repeatInsert, then inserts pair.close. Wrapped in cm.operation() for undo atomicity. Counted dot-repeat (2.) repeats the text inside one set of delimiters. This exceeds both vim-surround and nvim-surround, where insert-mode surround dot-repeat is broken (nvim-surround #301). (#82)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (createInsertModeChanges, recordLastEdit, surroundInsert, surroundInsertNewline, replaySurroundAwareInsert in repeatLastEdit, onCursorActivity), ~/Repos/codemirror-vim/src/types.ts (_surroundInsertChar, _surroundInsertNewline fields)

Tests

  • 9 fork tests in ~/Repos/codemirror-vim/test/vim_test.js: dot_insert_surround_unspaced, dot_insert_surround_spaced, dot_insert_surround_quotes, dot_insert_surround_empty, dot_insert_surround_counted, dot_insert_surround_no_cross_session_leak, dot_insert_surround_no_leak_after_o, dot_insert_surround_before_text_lost, dot_insert_surround_alias_b

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Marked insert-mode surround dot-repeat as fixed; separated macro recording limitation into own section
  • README.md: Updated surround feature description with insert-mode dot-repeat
  • AGENTS.md: Updated codemirror-vim fork description with insert-mode surround dot-repeat and test count (1870)
  • DIFFERENCES.md (fork): Updated insert-mode surround section with dot-repeat implementation details
  • docs/features/surround.md: Updated insert mode and dot-repeat sections with insert-mode dot-repeat behavior

[0.88.0] - 2026-07-30

Added

  • Yank-ring dot-repeat — pressing . after paste cycling (p + <C-p>/<C-n>) now repeats the final cycled text instead of the original paste. On cycling exit, the final cycled content is written to the original paste register. The fork’s repeatLastEdit re-reads the register at replay time. Follows yanky.nvim’s update_register_on_cycle semantics. System clipboard registers ("+/"*) are excluded.
    • Plugin: src/vim/yank-ring.ts (originalPasteRegister tracking, getPasteRegisterName(), register write in cancel(), setVim())
  • undefineEx fork API — the codemirror-vim fork now exposes Vim.undefineEx(name) to remove ex commands registered via defineEx. Cleans both the exCommands function map and commandMap_ prefix lookup. Returns true if the command existed, false otherwise.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (undefineEx on vimApi)
  • Exmap unregistration on vimrc soft-reload — removing an exmap definition from the vimrc file now unregisters the old handler on save. Exmap names are tracked per vimrc load in vimrcExmapNames and cleaned via undefineEx before re-applying on soft-reload. Plugin-defined and fork built-in ex commands are unaffected.
    • Plugin: src/main.ts (vimrcExmapNames Set, cleanup in softReloadVimrc), src/vimrc/loader.ts (exmapNames in ApplyResult and VimrcLoadResult), src/types/vim-api.d.ts (undefineEx type, setText on registers, lastEditInputState on VimState)

Tests

  • 3 fork tests in ~/Repos/codemirror-vim/test/vim_test.js: ex_undefineEx (define → undefine → verify removed), ex_undefineEx_nonexistent (returns false), ex_undefineEx_short_name (short name prefix cleaned)
  • 3 e2e tests in test/specs/yank-ring.e2e.ts: dot-repeat after single cycle pastes cycled text, dot-repeat without cycling pastes original (regression), single cycle then dot-repeat pastes cycled text
  • 4 e2e tests in test/specs/vimrc-exmap-reload.e2e.ts: vimrcExmapNames field exists, undefineEx available on Vim API, returns false for nonexistent, defineEx + undefineEx round-trip with built-in survival

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Removed :earlier/:later and :sign from N/A table (contradicted implemented features); clarified flash dot-repeat as working correctly; updated exmap soft-reload to reflect unregistration support; updated yank-ring dot-repeat as fixed
  • README.md: Updated yank-ring feature description with dot-repeat
  • AGENTS.md: Updated codemirror-vim fork description with undefineEx API and test count
  • docs/features/quality-of-life.md: Updated yank-ring section with dot-repeat behavior
  • docs/configuration/vimrc.md: Updated soft-reload section — exmap removal now works

[0.87.0] - 2026-07-29

Added

  • Hotkey conflict detection wizard — on plugin load, detects when Obsidian’s default hotkeys (Ctrl+W, Ctrl+D, Ctrl+F, Ctrl+B) conflict with workspace navigation keys. Shows a one-time Notice per plugin version with a “Check hotkey conflicts” button in Settings → Vim Motions → Navigation that lists each active conflict with step-by-step unbinding instructions. Skipped on mobile and when workspace nav is disabled.
    • Plugin: src/workspace/hotkey-conflicts.ts (new — conflict detection via hotkeys.json, VimInfoModal display), src/settings.ts (conflictNoticeDismissedVersion setting, button in both declarative and imperative settings paths), src/main.ts (detection on onLayoutReady)
  • Per-view CursorMoved/TextYankPost/CursorHold/CmdlineEnter/CmdlineLeave autocmd events — extends the per-view autocmd pattern (already implemented for mode events via AutocmdModeWatcher) to 5 additional events. CursorMoved fires independently per view with position-change detection (only fires when cursor actually moved). TextYankPost fires from any view including popovers. CursorHold fires per-view with configurable delay. Built-in vim mode retains active-leaf-only behavior.
    • Plugin: src/vim/autocmd-event-watcher.ts (new — AutocmdEventWatcher ViewPlugin), src/lua/autocmd.ts (useEventViewPlugin flag, per-view handler methods, gated legacy bindings), src/main.ts (extension registration, callback wiring, hold delay sync)
  • Visual-mode paste cycling — yank-ring paste cycling now works after visual-mode paste (viw + p + <C-p> to cycle). Detects visual paste via anchor/cursor position comparison at snapshot time. Computes paste range via doc-length arithmetic. Visual block paste is excluded. Normal-mode paste cycling is unaffected.
    • Plugin: src/vim/yank-ring.ts (snapshot() helper, posMin(), visual paste detection in onCommandDone, prevAnchor/prevDocLength/prevSelectionLength/prevVisualLine/prevVisualBlock tracking)
  • Console warning for unknown set options — unknown set options in vimrc now produce a console.warn on first encounter per vimrc load. Options recognized by either the plugin (KNOWN_SET_OPTIONS) or CM Vim built-in options are not warned about. Deduplication prevents repeated warnings for the same option.
    • Plugin: src/vimrc/loader.ts (KNOWN_CM_VIM_OPTIONS set, warnedSetOptions deduplication, clearSetOptionWarnings())

Changed

  • Flash count prefix now honored with labels — 3f{char} with 2+ matches now jumps directly to the 3rd match without showing the label overlay. When the count exceeds available matches, the last match is used (Neovim parity). f{char} without a count prefix still shows labels for 2+ matches. Works in operator-pending mode (d3f{char}) and with t/T till motions.
    • Plugin: src/flash/char-mode.ts (count prefix check before label overlay)
  • Flash dot-repeat clarified — dot-repeat after df{char}{label} already works correctly. The fork stores the resolved position via _asyncMotionTarget and repeatLastEdit replays the operator to the same relative offset. The label UI does not re-appear (correct vim behavior). KNOWN_LIMITATIONS.md entry clarified.

Fixed

  • Cursor focus lost when pressing Tab to navigate cells in Embedded table widget — pressing Tab in insert mode inside an embedded table cell editor froze the editor. The cursor disappeared, vim mode got stuck in Insert mode, and Escape stopped working. Root cause: exitCellEdit() scheduled a 50ms refreshAfterOp() timer that was non-cancellable and had no state guard. When Tab called exitCellEdit() → enterCellEdit() synchronously, the deferred refresh fired while the new cell editor was active — removing its key handlers, potentially rebuilding the widget DOM (orphaning the editor), and leaving the controller in an inconsistent state (cell-edit state with table-nav handlers). Fixed with four layers of defense: (1) refreshAfterOp() now stores and deduplicates the timer ID in a refreshTimer member, cancelled in exitTable(), enterCellEdit(), and destroy(). (2) doRefreshAfterOp() guards against firing in cell-edit or inactive state. (3) exitCellEdit() accepts { skipRefresh: true } — the Tab handler skips both setActiveEditTableRange(null) and refreshAfterOp() to prevent widget DOM rebuilds during cell-to-cell transitions. (4) enterCellEdit() cancels any pending refresh timer as belt-and-suspenders protection. Additionally, Tab at the last cell of the last row (or Shift-Tab at the first cell) now returns to table-nav mode instead of silently re-entering the same cell. (#92)
    • Plugin: src/vim/table-nav-controller.ts (refreshTimer member, refreshAfterOp timer storage/dedup, doRefreshAfterOp state guard, exitCellEdit skipRefresh param, handleCellEditKey rewrite with widget re-query and boundary handling, enterCellEdit timer cancel and pendingD cleanup)
  • Visual mode highlighting in embedded table cell editors — entering charwise visual mode (v) in an embedded table cell editor now shows selection highlighting. The cell editor’s CM6 instance doesn’t receive .cm-focused, which previously caused the browser to hide ::selection highlights. Fixed by adding a CSSStyleSheet on document.adoptedStyleSheets that forces ::selection visibility in .cm-vimVisual:not(.cm-vimVisualLine) scoped to .vim-table-cell-editor. Linewise visual mode (V) already worked via the fork’s focus-independent linewiseVisualHighlight ViewPlugin. (#19)
    • Plugin: src/vim/table-cell-editor.ts (visualSelectionSheet via adoptedStyleSheets)
  • Undo tree memory eviction on file close — in-memory undo trees (undoTreeMap) are now evicted when all editors for a file are closed, preventing unbounded memory growth in long sessions. Dirty trees are persisted before eviction when undoFile is enabled. Persisted data on disk is not deleted — reopening a file restores from persistence or starts fresh.
    • Plugin: src/main.ts (undo tree eviction in active-leaf-change handler)
  • Undo tree stale-tree notification — when undoFile is enabled and a file was modified outside Obsidian between sessions, an Obsidian Notice is now shown when the persisted undo tree’s docLength doesn’t match the current file size. Detection fires at most once per file per session. Legacy persisted trees without docLength gracefully skip the check.
    • Plugin: src/main.ts (activateUndoTreeForFile — docLength comparison + Notice), src/vim/undo-tree.ts (docLength field on SerializedUndoTree)
  • vim.v.insertmode now populated — returns 'i' for insert mode, 'r' for replace mode (R), 'v' for virtual replace mode (gR), and '' in normal/visual modes. Available in keymap function callbacks via getInsertModeChar(). Autocmd callbacks default to '' (no adapter context available).
    • Plugin: src/lua/api.ts (getInsertModeChar helper, insertmode added to 5 setVimVContext callsites)

Tests

  • 2 unit tests in test/unit/undo-tree.test.ts: docLength round-trip preservation, legacy data without docLength graceful deserialization
  • 9 unit tests in test/unit/lua/vim-v.test.ts: insertmode 'r'/'v' context values, 7 getInsertModeChar tests (null, normal, insert, replace, virtual replace, priority, missing state)
  • 2 unit tests in test/unit/textarea-vim.test.ts: clearSetOptionWarnings export and idempotency
  • 5 e2e tests in test/specs/flash-char-mode.e2e.ts: 3fa direct jump, 5fa clamp to last match, 2fa direct jump, d3fa operator-pending, 1fa single match
  • 8 unit tests in test/unit/hotkey-conflicts.test.ts: conflict array structure, detection logic (empty, full, partial, custom binding, unrelated keys)
  • 10 unit tests in test/unit/vim/autocmd-event-watcher.test.ts: callback wiring (set/clear/extension), CursorMoved detection (fires on move, skips unchanged, fires on each distinct move), CursorHold timer (fires after delay, resets on new move, custom delay), TextYankPost
  • 1 e2e test in test/specs/yank-ring.e2e.ts: normal-mode paste cycling regression after visual-paste changes

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Reorganized — moved 20 fixed top-level sections to new “Resolved Issues” section at bottom; updated flash count prefix, undo tree eviction, undo tree stale notification, visual mode cell editor, vim.v.insertmode, exmap soft-reload, unknown set option, flash dot-repeat, hotkey conflicts, per-view autocmd events, visual paste cycling, and SettingDefinitionList investigation entries
  • docs/features/flash.md: Added count prefix behavior and dot-repeat note
  • docs/features/undo-tree.md: Updated memory management and stale-tree notification
  • docs/features/tables.md: Updated visual mode highlighting fix in cell editors
  • docs/features/quality-of-life.md: Updated yank-ring with visual-mode paste cycling
  • docs/configuration/lua-config.md: Updated vim.v.insertmode from deferred to active, updated per-view autocmd event list
  • docs/configuration/vimrc.md: Added unknown set option warning behavior, updated exmap soft-reload
  • docs/configuration/settings.md: Added hotkey conflict detection button to workspace navigation settings
  • docs/getting-started/recommended-setup.md: Added hotkey conflict wizard note
  • README.md: Updated flash motions, workspace navigation, and Lua configuration feature descriptions
  • AGENTS.md: Updated autocmd event list with per-view CursorMoved/TextYankPost/CursorHold/CmdlineEnter/CmdlineLeave

[0.86.0] - 2026-07-28

Fixed

  • Which-key displays inaccurate count of group subcommands in “all” mode — when whichKeyMode was set to “All partial keys” and the user pressed the leader key, the which-key popup showed wildly inflated (+N) group counts (e.g., (+418) instead of (+21)). The “All partial keys” code path (showCompletions()) queried vim.getCompletions() from the CM vim engine, which returns the entire defaultKeymap array — including built-in defaults, plugin-internal keymaps, and user-defined keymaps — instead of using only the leaderBindings registry (which contains only user-visible leader keymaps). The “Leader key only” mode (showLeaderBindings()) was unaffected because it already used leaderBindings directly. Fixed by adding an isLeaderScope branch in showCompletions() that mirrors showLeaderBindings() — building entries from this.leaderBindings filtered by the current prefix, with correct label/icon/color resolution and leader-style title formatting. Non-leader completions (g, z, d, etc.) continue using vim.getCompletions() as before. (#91)
    • Plugin: src/ui/which-key.ts (showCompletions — isLeaderScope branch, deferred getCompletions to non-leader else branch)
  • Several vim.opt and vimrc set options produce “unknown vim.opt option” warning — 12 plugin settings were documented but never registered in the Lua vim.opt proxy (KNOWN_SET_OPTIONS) or the vimrc :set pathway (vim.defineOption). Setting them via vim.opt.yankring = true or set yankring in vimrc logged a console warning and had no effect. Fixed by adding all 12 options to both registries. Options now work identically across Settings UI, vimrc, and Lua. (#90)
    • Added to both KNOWN_SET_OPTIONS and vim.defineOption: yankring, yankhighlightmode, yankhighlightduration, undotree, undofile, undotreemaxnodes, foldawarenavigation, foldpersistence, harpoon, dial
    • Added to KNOWN_SET_OPTIONS only (already had vim.defineOption): jumplist, jumplistsize
    • Plugin: src/vimrc/loader.ts (12 new KNOWN_SET_OPTIONS entries + setJumpListEnabled/setJumpListSize imports), src/vim/options.ts (10 new vim.defineOption calls + 2 new exported setters)

Tests

  • 1 e2e test in test/specs/lua-space-leader.e2e.ts: which-key group count in “all” mode with space leader stays bounded to actual leader bindings (not inflated by engine-internal keymaps)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added which-key inflated group count fix; added vim.opt/vimrc option parity fix
  • docs/configuration/lua-config.md: Added 8 missing options to vim.opt table (harpoon, dial, jumplist, foldawarenavigation, foldpersistence, jumplistsize, yankhighlightduration, yankhighlightmode)
  • docs/configuration/vimrc.md: Added 6 missing options to vimrc tables (harpoon, dial, foldawarenavigation, foldpersistence, yankhighlightmode, yankhighlightduration)

[0.85.0] - 2026-07-28

Fixed

  • Scroll jumps to cursor when interacting with Meta Bind or other plugin fields in the properties panel — the propertiesFoldObserver in fold-sync.ts watched .metadata-container for any class attribute mutation and unconditionally dispatched EditorView.scrollIntoView(selection.main.head). Plugins like Meta Bind that render interactive inputs in the properties area trigger class mutations that are not fold toggles, causing the editor to scroll back to the last vim cursor position. Fixed by adding attributeOldValue: true to the MutationObserver config and comparing the old vs new is-collapsed class presence — the observer now only fires scrollCursorIntoView() when the fold state actually changes. No-op mutations (identical class string) and non-fold mutations (any class other than is-collapsed) are ignored. (#89)
    • Plugin: src/vim/fold-sync.ts (propertiesFoldObserver — is-collapsed filter, attributeOldValue: true)

Tests

  • 4 e2e tests in test/specs/properties-fold-scroll.e2e.ts: non-fold class mutation preserves scroll position, no-op class re-assignment preserves scroll position, fold toggle triggers scroll, unfold toggle triggers scroll
  • Spike test test/specs/spikes/spike-metabind-scroll-issue89.e2e.ts: 8 diagnostic tests confirming root cause (class mutation scroll jump, observer attribution, split-view behavior, class mutation audit)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added properties fold observer scroll fix
  • CONTRIBUTING.md: Updated fold-sync.ts description with is-collapsed filter
  • docs/features/workspace-navigation.md: Updated fold scroll behavior note

[0.84.0] - 2026-07-25

Fixed

  • Hint mode labels missing on wikilinks and markdown links when cursor is on the same line — in Live Preview, wikilinks on the cursor’s line render as .cm-hmd-internal-link spans (not .cm-underline), and markdown links render as .cm-link/.cm-url spans. These were not in TARGET_SELECTOR, so no hint labels appeared. In Source mode, wikilinks always render as .cm-hmd-internal-link and were similarly missed. Fixed by adding .cm-hmd-internal-link, .cm-link, and .cm-url to OBSIDIAN_SELECTORS and extending classifyTarget() to resolve links from these elements via the existing resolveCmUnderlineHref() pipeline. Deduplication filters prevent multiple hints per link: aliased wikilink sub-spans, nested .cm-underline inside .cm-hmd-internal-link, formatting bracket spans, and markdown link URL spans when a text span exists. (#85)
    • Plugin: src/ui/hint-mode.ts (added selectors, extended classifyTarget, deduplication filters in createHintAction)
  • Hint mode link resolution fails in Obsidian runtime — getEditorViewFromElement() used the DOM .cmView.view property to access the CM6 EditorView, but this property is not accessible in Obsidian’s runtime environment (only works in the WDIO test context). All resolved links returned href: undefined, causing hint labels to appear but do nothing when activated. Fixed by falling back to the MarkdownView.editor.cm path (the same accessor used by the rest of the codebase via getEditorView() in src/util/editor.ts). (#85)
    • Plugin: src/ui/hint-mode.ts (getEditorViewFromElement fallback via app.workspace.getActiveViewOfType(MarkdownView))
  • Hint mode does not open external URLs from editor links — when resolveCmUnderlineHref() resolved an external URL (e.g., https://example.com), hintActivate() fell through to the generic click handler because the isInternalLink check excluded URLs starting with http:// or https://. The generic click handler dispatches pointer/click events on <span> elements, which have no click handler and produce no effect. Fixed by adding an explicit window.open(linkHref) path for external URLs. (#85)
    • Plugin: src/ui/hint-mode.ts (hintActivate external URL branch)

Tests

  • 9 new e2e tests in test/specs/hint-mode-links.e2e.ts: Source mode navigation (plain, aliased, inline wikilinks), cursor-on-line Live Preview navigation, multiple wikilinks on same line, aliased wikilink deduplication, yf yank on wikilink, F open-in-new-tab on wikilink, embed wikilink hint visibility
  • Mode-switching helpers (ensureLivePreview, ensureSourceMode, isLivePreview, isSourceMode) extracted to test/helpers.ts
  • Spike test test/specs/spikes/spike-hint-wikilink-issue85.e2e.ts: 17 diagnostic tests probing DOM element discovery, posAtDOM mapping accuracy, findLinkAtCursor resolution, and end-to-end hint activation across Live Preview, Source mode, and Reading view

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated hint mode target classification with .cm-hmd-internal-link, .cm-link, .cm-url selectors, deduplication filter descriptions, EditorView fallback, and external URL handling
  • CONTRIBUTING.md: Updated hint-mode.ts description with cursor-on-line and Source mode link resolution, EditorView MarkdownView fallback, external URL handling
  • docs/features/hint-mode.md: Updated link handling section with Source mode support, cursor-on-line behavior, and external URL opening

[0.83.0] - 2026-07-25

Fixed

  • Autocmd mode events only fire in the active editor leaf — InsertEnter, InsertLeave, and ModeChanged autocmd events now fire per-view across all editors (split panes, popover hover-preview editors, canvas card text inputs) when using the bundled vim fork. Previously, these events only fired for the active workspace leaf because the AutocmdManager bound to a single adapter via onActiveLeafChange(). Non-leaf editors (popovers, canvas cards) never triggered active-leaf-change, so autocmd callbacks for mode events never executed in these contexts. Fixed by adding AutocmdModeWatcher, a CM6 ViewPlugin that hooks vim-mode-change per-EditorView and fires mode events through AutocmdManager.fire(). The ViewPlugin is registered via registerEditorExtension() and automatically applies to all editors. The single-adapter mode-change binding in bindAdapter() and activate() is gated by a useViewPlugin flag — when the ViewPlugin is active (bundled vim mode), the legacy binding is skipped. Built-in vim mode retains the existing active-leaf-only behavior. Other adapter-dependent events (TextYankPost, CursorMoved, CursorHold, CmdlineEnter, CmdlineLeave) remain active-leaf-only for v1. (#88)
    • Plugin: src/vim/autocmd-mode-watcher.ts (new — AutocmdModeWatcher ViewPlugin, setAutocmdModeCallbacks/clearAutocmdModeCallbacks), src/lua/autocmd.ts (useViewPlugin flag, setUseViewPlugin(), handleModeChangeFromView(), guarded onModeChange in activate() and bindAdapter()), src/main.ts (extension registration, callback wiring in loadLuaConfigInternal, cleanup in onunload)
  • Hint mode labels missing on wikilinks and markdown links when cursor is on the same line — in Live Preview, wikilinks on the cursor’s line render as .cm-hmd-internal-link spans (not .cm-underline), and markdown links render as .cm-link/.cm-url spans. These were not in TARGET_SELECTOR, so no hint labels appeared. In Source mode, wikilinks always render as .cm-hmd-internal-link and were similarly missed. Fixed by adding .cm-hmd-internal-link, .cm-link, and .cm-url to OBSIDIAN_SELECTORS and extending classifyTarget() to resolve links from these elements via the existing resolveCmUnderlineHref() pipeline. Deduplication filters prevent multiple hints per link: aliased wikilink sub-spans, nested .cm-underline inside .cm-hmd-internal-link, formatting bracket spans, and markdown link URL spans when a text span exists. (#85)
    • Plugin: src/ui/hint-mode.ts (added selectors, extended classifyTarget, deduplication filters in createHintAction)

Tests

  • 3 new unit tests in test/unit/lua/autocmd.test.ts: handleModeChangeFromView fires events, bindAdapter skips mode-change when useViewPlugin is true, activate skips onModeChange when useViewPlugin is true
  • 7 unit tests in test/unit/vim/autocmd-mode-watcher.test.ts: callback set/clear/overwrite, extension creation, mode payload forwarding, cleanup after clear
  • 4 e2e tests in test/specs/lua-autocmd-perview.e2e.ts: InsertEnter fires exactly once in active leaf (no double-firing), InsertEnter fires in non-active split via Vim.handleKey, ModeChanged fires in non-active split with correct pattern, InsertLeave fires in non-active split
  • Spike tests: test/specs/spikes/spike-autocmd-multiview.e2e.ts (13 tests — multi-view event discovery), test/specs/spikes/spike-autocmd-popover-timing.e2e.ts (12 tests — popover/timing analysis)
  • 9 new e2e tests in test/specs/hint-mode-links.e2e.ts: Source mode navigation (plain, aliased, inline wikilinks), cursor-on-line Live Preview navigation, multiple wikilinks on same line, aliased wikilink deduplication, yf yank on wikilink, F open-in-new-tab on wikilink, embed wikilink hint visibility
  • Mode-switching helpers (ensureLivePreview, ensureSourceMode, isLivePreview, isSourceMode) extracted to test/helpers.ts
  • Spike test test/specs/spikes/spike-hint-wikilink-issue85.e2e.ts: 17 diagnostic tests probing DOM element discovery, posAtDOM mapping accuracy, findLinkAtCursor resolution, and end-to-end hint activation across Live Preview, Source mode, and Reading view

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added per-view mode events section documenting which events fire per-view, which remain active-leaf-only, getModeState() semantics, and vim.obsidian.mode() behavior; updated hint mode target classification with .cm-hmd-internal-link, .cm-link, .cm-url selectors and deduplication filter descriptions
  • docs/configuration/lua-config.md: Added per-view callout to autocommands section, marked InsertEnter/InsertLeave/ModeChanged as “(per-view)” in events table
  • docs/features/hint-mode.md: Updated internal link handling section with Source mode support and cursor-on-line behavior
  • CONTRIBUTING.md: Added autocmd-mode-watcher.ts to codebase structure; updated hint-mode.ts description with cursor-on-line and Source mode link resolution
  • AGENTS.md: Updated Lua API description noting per-view autocmd mode events
  • README.md: Updated Lua configuration feature description with per-view mode events

[0.82.0] - 2026-07-24

Fixed

  • Animated cursor does not animate for count-prefixed and multi-key motions — movements like 4j (count-prefixed) and g$ (multi-key) caused the cursor to teleport instead of animating. The resolveVimMode() method in the animated cursor controller used vim.status (the chord display string) to detect operator-pending mode. Since vim.status is set on every keystroke (e.g., "4" when typing a count digit, "g" when typing a prefix key), any multi-keystroke motion triggered a false mode change to operator-pending — which has a different cursor shape (underline vs block). Each shape change called snap(), bypassing the animation entirely. Fixed by removing vim.status from the operator-pending detection — only inputState.operator (set when an actual operator like d/c/y is registered) now gates the operator-pending mode. (#86)
    • Plugin: src/vim/animated-cursor/controller.ts (resolveVimMode — removed vim.status check)
  • Hint mode does not navigate wikilinks or markdown links in Live Preview — typing the hint label for a wikilink ([[Target]]) or markdown link ([text](Target)) in the editor did nothing. The .cm-underline spans rendered by Live Preview are <span> elements without href or data-href attributes — classifyTarget correctly identified them as links but extracted href: undefined, causing hintActivate to fall through to the generic click handler (which does nothing useful on CM6 spans). Fixed by adding resolveCmUnderlineHref() which uses the CM6 EditorView.posAtDOM() API to convert the DOM element to a document offset, then calls the existing findLinkAtCursor() regex from goto-definition.ts to extract the link target from the raw markdown text. Works for wikilinks (including aliased and heading links), markdown links (internal and external), and bare URLs. Reading view and frontmatter property links were unaffected (they use <a> elements with proper href/data-href attributes). (#85)
    • Plugin: src/ui/hint-mode.ts (getEditorViewFromElement, resolveCmUnderlineHref, updated classifyTarget link branch)
  • Input method not restored after manual IME switch during insert mode — when a user manually switched input methods while in insert mode (e.g., from Vietnamese to English via OS keyboard shortcut), pressing Esc then i reset the IME to the original input method instead of preserving the manually chosen one. The save() method in ImSwitcher cached the stale lastKnownIm value (set by the plugin’s last set() call) before querying the OS for the actual current IME. The async OS query updated lastKnownIm but never wrote back to savedImByLeaf, so restore() always read the stale value. Fixed by making save() async — it now queries the OS for the real IME state first, then caches the result in both lastKnownIm and savedImByLeaf. onInsertLeave() awaits the save before switching to the normal-mode default IME. Falls back to lastKnownIm when the OS query fails (e.g., binary timeout). (#83)
    • Plugin: src/im/im-switcher.ts (save() async with OS query, onInsertLeave() awaits save, debouncedSwitch/pendingSwitch accept async callbacks), src/lua/api.ts (imSave type updated), src/lua/loader.ts (fire-and-forget async save), src/lua/obsidian-api.ts (void floating promise)

Tests

  • 10 e2e tests in test/specs/hint-mode-links.e2e.ts: wikilink/markdown-link/bare-URL href resolution from .cm-underline spans, wikilink navigation (plain and aliased), markdown link navigation, inline wikilink navigation, reading view regression, frontmatter property link regression, external link safety
  • Updated 10 unit tests in test/unit/im-switcher.test.ts: save() tests now verify OS query behavior (mock executeImGet return value instead of manually setting lastKnownIm), async settle via vi.advanceTimersByTimeAsync(0), new test for fallback when OS query returns null

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added animated cursor multi-key motion fix; added hint mode link navigation fix to hint mode actions section
  • CONTRIBUTING.md: Updated hint-mode.ts description with link resolution via posAtDOM
  • docs/features/hint-mode.md: Updated internal link handling section with Live Preview resolution details
  • KNOWN_LIMITATIONS.md: Updated input method switching section with manual IME switch fix
  • CONTRIBUTING.md: Updated im-switcher.ts description
  • AGENTS.md: No changes needed (existing description already covers per-view IM switching)

[0.81.0] - 2026-07-23

Fixed

  • EasyMotion capital letter search not working — EasyMotion character search motions (<leader><leader>s, <leader><leader>f, etc.) failed when typing a capital letter (Shift+key) as the search character. The waitForKey() handler resolved on the Shift keydown event (before the actual character key arrived), causing the motion to silently abort. Fixed by adding a modifier-key guard matching the existing pattern in waitForLabel() — e.key.length !== 1 keys are now suppressed and ignored, keeping the handler alive for the real character. (#84)
    • Plugin: src/easymotion/keypress.ts (waitForKey modifier-key guard)

Tests

  • 6 unit tests in test/unit/easymotion-keypress.test.ts: waitForKey resolves single character keys and Escape, ignores Shift/Control/Alt/Meta modifier-only keys
  • 1 e2e test in test/specs/easymotion-comprehensive.e2e.ts: EasyMotion bidirectional char search with capital letter (Z) input

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added capital letter fix to EasyMotion operator-pending section
  • CONTRIBUTING.md: Updated keypress.ts description with modifier-key guard
  • docs/features/easymotion.md: Added note about capital letter support in find motions

[0.80.0] - 2026-07-23

Fixed

  • IME change detection limited to primary editor leaf — IME composition tracking and input method switching now work across all editor views (split panes, Page Preview popovers, Canvas card editors). Previously, composition events and mode-change detection were wired to a single element/adapter obtained from getActiveViewOfType(MarkdownView), so non-primary editors never received IME handling. Fixed with two new CM6 ViewPlugins registered via registerEditorExtension(): CompositionTracker tracks compositionstart/compositionend per-EditorView, and ImModeWatcher binds adapter.on('vim-mode-change') per-EditorView to detect insert mode transitions. The autocmd-based IM switch registrations (InsertEnter/InsertLeave/CmdlineLeave) are replaced by the per-view mechanism; Lua autocmd callbacks continue to fire for the primary leaf via AutocmdManager (unchanged contract). (#83)
    • Plugin: src/im/composition-tracker.ts (new), src/im/im-mode-watcher.ts (new), src/im/im-switcher.ts (refactored — removed single-element tracking, added cleanupView()), src/main.ts (registered extensions, removed autocmd-based IM registrations)

Tests

  • 15 unit tests in test/unit/composition-tracker.test.ts: per-view composing state, multi-tracker isolation, destroy cleanup, onAllCompositionsEnd callback lifecycle, unsubscribe
  • 14 unit tests in test/unit/im-mode-watcher.test.ts: lazy adapter binding, mode change detection (insert/leave/replace), adapter re-binding, cleanup, multiple views with unique IDs
  • 4 e2e tests in test/specs/ime-composition-multiview.e2e.ts: composition tracking on active/non-active editors, independent per-view tracking, insert mode detection on non-active editor

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated input method switching section with multi-view fix
  • README.md: Updated input method switching feature description with multi-view support
  • CONTRIBUTING.md: Added composition-tracker.ts and im-mode-watcher.ts to codebase structure
  • AGENTS.md: Updated Lua API description noting per-view IM switching

[0.79.0] - 2026-07-22

Added

  • vim.v namespace — Neovim-compatible predefined variables — read-only metatable proxy exposing vim.v.count, vim.v.count1, vim.v.register, vim.v.operator (Tier 1), vim.v.searchforward (read/write), vim.v.insertmode, vim.v.numbermax/numbermin/numbersize, vim.v.true/false/null (Tier 2), and vim.v.foldstart/foldend/foldlevel/folddashes, vim.v.lnum/relnum/virtnum, vim.v.char, vim.v.hlsearch, vim.v.event (Tier 3 — context-dependent). Context is set from actionArgs before each keymap callback invocation and cleared after. vim.v.event is populated during autocmd dispatch with the event data table. vim.v.hlsearch queries the fork’s search overlay state via getSearchState(cm).getOverlay().
    • Plugin: src/lua/api.ts (VimVContext, setVimVContext, clearVimVContext, vim.v metatable, autocmd vim.v.event wiring), src/lua/loader.ts (getVimApi, getSearchForward, setSearchForward, getHlSearch callbacks), src/types/vim-api.d.ts (ActionArgs extended, feedKeys and getOverlay added to VimApi), src/lua/engine.ts (EXPR_INSTRUCTION_LIMIT)
  • { expr = true } keymap support — vim.keymap.set now accepts { expr = true } for function callbacks. The callback must return a string that is fed as keystrokes via the fork’s new feedKeys API. Sync-only — async APIs cannot be used in expr callbacks. String expr (Vimscript evaluation) is not supported with a helpful error guiding users to the function form. Recursion guard (200 depth, matching Neovim) prevents infinite expr → feedKeys → expr loops.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (feedKeys method on VimApi — delegates to doKeyToKey with noremap flag and recursion protection)
    • Plugin: src/lua/api.ts (expr callback path with lua_pcall(state, 0, 1, 0), return value capture, feedKeys invocation)

Tests

  • 55 unit tests in test/unit/lua/vim-v.test.ts: Tier 1 defaults and context (count, count1, register, operator), read-only enforcement, Tier 2 constants (numbermax/min/size, true/false/null), searchforward (callback read/write), insertmode, Tier 3 fold/statuscolumn/event/char variables, hlsearch callback (read from getHlSearch, fallback to context), vim.v.event in autocmd context (multi-field, nested data), expr mapping registration, unknown keys
  • 8 e2e tests in test/specs/lua-vim-v.e2e.ts: vim.v.count and vim.v.count1 with and without typed counts, vim.v.event populated during InsertEnter/InsertLeave autocmds, vim.v.hlsearch returns 1 after / search and 0 after :nohlsearch
  • 7 e2e tests in test/specs/lua-expr-mapping.e2e.ts: expr returns executed keys, expr with count, nil/empty return, error handling, string expr error, special keys

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added expr mapping limitations (string expr, async expr, operator-pending, count forwarding) and vim.v limitations (async callback reliability, outside-callback behavior)
  • docs/configuration/lua-config.md: Added vim.v section with all variable tables, expr mapping examples, and updated vim.keymap.set options table with expr documentation
  • README.md: Updated Lua configuration feature description with vim.v and expr mappings
  • CONTRIBUTING.md: Updated api.ts description with vim.v namespace and expr mapping support
  • AGENTS.md: Updated codemirror-vim fork description with feedKeys API, updated Lua API list with vim.v (20 variables including event/hlsearch)

[0.78.0] - 2026-07-22

Fixed

  • Fold gutter click does not unfold (continued) — the initial fix in 0.76.0 (correcting zero-width ranges in the plugin’s own fold-column and statuscolumn gutters) was insufficient because those gutters are off by default — the reporter was clicking Obsidian’s native fold gutter, which the plugin doesn’t control. CM6’s foldState requires an exact {from, to} match to remove a fold; a mismatched range is silently ignored. Fixed by adding unfoldNormalizerExtender in fold-sync.ts — a transactionExtender that detects mismatched unfoldEffect ranges and appends a corrective effect with the actual stored fold range. Works for all fold sources: Obsidian’s native gutter, the plugin’s custom gutters, and vim commands. (#80)
    • Plugin: src/vim/fold-sync.ts (unfoldNormalizerExtender)
  • Insert-mode surround cursor position and undo — <C-G>s{char} now inserts both the opening and closing delimiters immediately (matching vim-surround behavior) instead of deferring the close delimiter to exitInsertMode. Fixes: (1) cursor now lands on the last typed character after Esc (was on the closing delimiter), (2) undo is improved (was 3 steps: close, text, open — now 2 steps: text, delimiters), (3) dot-repeat degrades cleanly (replays only typed text, not garbled ()hello). The maybeReset mechanism clears delimiter text from the insert-mode change stream so lastInsertModeChanges.changes contains only user-typed text. Known limitation: dot-repeat replays only the typed text, not the surrounding delimiters. Macro recording of insert-mode surround keys is also not supported (pre-existing fork limitation). (#82)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (surroundInsert, surroundInsertNewline refactored; exitInsertMode deferred-close block removed), ~/Repos/codemirror-vim/src/types.ts (surroundInsertClose property removed)

Tests

  • 17 e2e tests in test/specs/fold-unfold-normalizer.e2e.ts: unfold normalizer for heading folds (exact-match, zero-width, wrong-to, line-boundary, vim zc/zo round-trip, zM/zR round-trip, no-op on non-folded line, multi-fold targeting), frontmatter folds in source mode (exact-match, zero-width, line-start mismatch, line-boundary, fold.from > line.from verification, vim zc/zo round-trip)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated fold gutter unfold fix with unfold normalizer extender description
  • CONTRIBUTING.md: Updated fold-sync.ts description with unfold normalizer
  • docs/features/workspace-navigation.md: Added unfold normalizer note to Folds section
  • README.md: Updated surround feature description with insert-mode cursor fix
  • AGENTS.md: Updated codemirror-vim fork description noting insert-mode surround refactor
  • docs/features/surround.md: Updated insert-mode cursor behavior description

[0.77.0] - 2026-07-22

Fixed

  • Animated cursor may not animate on Windows 11 — the canvas rAF loop could silently die on Windows due to several platform-specific behaviors: (1) Any transient error during a tick frame (null coordinate during window refocus, detached DOM node) threw an unhandled exception that permanently killed the requestAnimationFrame loop — the cursor disappeared until plugin reload. Fixed by wrapping the loop body in try/catch; errors are logged once and the loop continues. (2) Windows 11 Efficiency Mode, window occlusion tracking (CalculateNativeWinOcclusion), and high-resolution timer suppression can all silently stop rAF delivery without throwing. Added a 500ms setInterval heartbeat that detects a stalled loop and re-wakes it — unlike rAF, setInterval is not suppressed by Chromium’s occlusion tracker. (3) When the browser tab/window is hidden and restored, rAF may not resume. Added a visibilitychange listener that re-wakes the loop when the page regains visibility. (4) Windows displays at 125%/150% scaling produce fractional devicePixelRatio values (1.25/1.5). Canvas backing-store dimensions are now rounded with Math.round() to avoid sub-pixel aliasing and continuous compositor re-uploads. Informed by cursor-smith’s v1.1.8 fix for the same “cursor disappears until plugin reload” failure mode and terminal-workbench-cursor’s heartbeat safety-net pattern.
    • Plugin: src/vim/animated-cursor/manager.ts (try/catch in loop(), heartbeat setInterval, visibilitychange listener, DPR rounding)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added Windows resilience details to animated cursor section
  • CONTRIBUTING.md: Updated manager.ts description with resilience mechanisms
  • docs/features/animated-cursor.md: Added Windows resilience section
  • README.md: Updated animated cursor feature description with cross-platform resilience

[0.76.0] - 2026-07-22

Added

  • Per-view cursor suppression fork API — added setCursorSuppressedForView(view, suppressed), clearCursorSuppressedForView(view), and isCursorSuppressedForView(view) to the codemirror-vim fork. Per-view overrides take precedence over the global setCursorSuppressed state, allowing the plugin to selectively restore the native cursor in specific contexts (table cell editors, textarea overlays) or force suppression (table navigation) without affecting other editors. Overrides are automatically cleaned up when the editor’s BlockCursorPlugin is destroyed.
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (per-view state map, API implementation, cleanup in destroy)
  • Animated cursor vimrc/Lua configuration — all 8 animated cursor settings are now configurable via vimrc (set smoothcursor, set smoothcursorsmoothness=0.3, etc.) and Lua (vim.opt.smoothcursor = true, etc.). Master toggle smoothcursor enables/disables the feature with reloadFeatures(). Sub-options (smoothcursorglide, smoothcursorsmoothness, smoothcursorsmear, smoothcursorstiffness, smoothcursortrailstiffness, smoothcursordamping, smoothcursormaxlength) hot-reload without restart. All use SideEffectOpt pattern syncing both settings[key] and module-level config. Short aliases: sc, scg, scs, scm, scst, scts, scd, scml. (#78)
    • Plugin: src/vimrc/loader.ts (16 SideEffectOpt entries), src/settings.ts (animatedCursor added to RELOAD_KEYS)
  • Animated cursor in oil explorer — animated cursor now renders in the oil file explorer. Single shared canvas architecture: one canvas on .app-container owned by AnimatedCursorManager, shared by all controllers. Reduces memory from O(N × viewport) to O(1 × viewport). Each controller clips drawing to its own editor bounds via ctx.clip(). MAX_CONTROLLERS raised from 8 to 16 with warning log on capacity. Canvas lifecycle managed by the manager (created on first register, removed when last controller deregisters). Null-check on canvas.getContext('2d') for browser canvas limits. Table cell editors and textarea vim overlays fall back to the native cursor. (#78)
    • Plugin: src/vim/animated-cursor/manager.ts (shared canvas ownership, sizing, lifecycle), src/vim/animated-cursor/controller.ts (removed per-controller canvas, draws on shared context), src/oil/oil-view.ts (injects createAnimatedCursorExtension() when enabled)

Fixed

  • Fold gutter click does not unfold — clicking a fold marker (▾) in the fold column or statuscolumn gutter folded the region correctly but clicking again to unfold had no effect. The unfoldEffect was dispatched with { from: line.from, to: line.from } (zero-width range) instead of the actual fold range, so CodeMirror found no matching fold decoration to remove. Fixed by capturing the fold’s end position from foldedRanges().between() and passing the full { from, to } range to unfoldEffect. (#80)
    • Plugin: src/vim/fold-column.ts (click handler), src/vim/statuscolumn.ts (handleFoldClick)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added fold gutter unfold fix to folding section
  • README.md: Added vimrc/Lua configuration to animated cursor feature description
  • CONTRIBUTING.md: Updated manager.ts and config.ts descriptions for Phase 3 architecture
  • AGENTS.md: Updated codemirror-vim fork description with per-view cursor suppression API
  • docs/features/animated-cursor.md: Added vimrc/Lua configuration section and oil explorer support section
  • docs/configuration/vimrc.md: Added smoothcursor, smoothcursorsmoothness, smoothcursorsmear to boolean options; added smoothcursorsmoothness, smoothcursorstiffness, smoothcursortrailstiffness, smoothcursordamping, smoothcursormaxlength to number options
  • docs/configuration/lua-config.md: Added all 8 smoothcursor* entries to vim.opt options table

[0.75.1] - 2026-07-22

Fixed

  • Table navigation cursor ghost — both native and animated cursors are now hidden during embedded table navigation. Early suppression in the ViewPlugin update cycle eliminates the brief cursor flash when entering a table. The animated cursor snaps to the exit position (no interpolation) when resuming after table navigation to prevent cross-table “ghost” trails.

    • Plugin: src/vim/table-nav-controller.ts (calls setCursorSuppressedForView and pauseAnimatedCursorForView)
  • Textarea overlay invisible cursor — the native cursor is now restored in textarea vim overlays by un-suppressing it for the overlay’s editor view. Previously, the global suppression for the animated cursor made the native cursor invisible in the overlay where the animated cursor doesn’t render.

    • Plugin: src/vim/textarea-vim-manager.ts (calls setCursorSuppressedForView(view, false))
  • Table cell editor cursor inconsistency — per-view un-suppression ensures the native cursor always renders inside embedded table cell editors, matching the behavior of textarea overlays.

    • Plugin: src/vim/table-cell-editor.ts (calls setCursorSuppressedForView(view, false))
  • Animated cursor stays as block in operator-pending mode — pressing d, c, y, or other operators without a motion kept the cursor as a block instead of switching to the configured operator-pending shape (default: underline). Two issues: (1) resolveVimMode() only checked vim.status (set for prompt-based pending like surround) but not vim.inputState.operator (set for standard operators like d/c/y). (2) Operator-pending is a transient state that doesn’t trigger CM6 transactions, so the ViewPlugin’s update() never fired. Fixed by checking inputState.operator in resolveVimMode() and polling the cursor shape every rAF frame in tick() to detect changes that bypass CM6’s transaction system. (#78)

    • Plugin: src/vim/animated-cursor/controller.ts (resolveVimMode checks inputState.operator, per-frame shape polling in tick)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Removed operator-pending detection from nice-to-have (implemented)

[0.75.0] - 2026-07-22

Added

  • Animated cursor blinking — the canvas cursor now blinks matching CM6’s default behavior (1200ms cycle, hard on/off toggle). After cursor movement, the cursor stays solid for 600ms before resuming blink. Blink epoch is aligned to the end of the reset delay so the first blink cycle starts cleanly. Blink only runs when the editor has focus; unfocused editors show a solid cursor. Suppressed during smear/smooth animation (cursor is moving). (#78)
    • Plugin: src/vim/animated-cursor/controller.ts (computeBlinkAlpha, lastMoveTime, blinkEpoch, focus-aware rAF loop)

Fixed

  • Animated cursor disappears below line ~28 — the canvas was sized to the viewport but positioned at the top of the scroll container (position: absolute; top: 0 inside scrollDOM). After scrolling, cursor coordinates pointed to positions below the canvas bounds. Fixed by moving the canvas to .app-container with position: fixed and using raw viewport-relative coordinates from coordsAtPos() directly — matching cursor-smith’s architecture. The canvas is clipped to the editor pane rect via ctx.clip() each frame. (#78)
    • Plugin: src/vim/animated-cursor/controller.ts (viewport-fixed canvas, removed scroll offset math), styles.css (fixed positioning)
  • Animated cursor displaced rightward in visual mode — entering visual mode (v) shifted the canvas cursor one character to the right. In visual mode with a forward selection (anchor < head), CM6’s selection.main.head points past the last selected character. The fork’s BlockCursorPlugin.measureCursor() decrements head in this case, but the animated cursor used the raw value. Fixed by applying the same head adjustment: when anchor < head and the character at head isn’t \n, decrement position by 1. (#78)
    • Plugin: src/vim/animated-cursor/controller.ts (head position adjustment in refreshTarget)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Marked cursor blink as fixed; removed “cursor blink after convergence” from nice-to-have (implemented)
  • docs/features/animated-cursor.md: Added cursor blinking section

[0.74.0] - 2026-07-21

Changed

  • Settings organized into 7 pages — the flat list of 20 settings groups is now organized into 7 navigable pages: General, Appearance, Navigation, Keybindings, Snippets & files, Input method, and Advanced. On Obsidian 1.13+, pages appear as sidebar entries via type: 'page' in getSettingDefinitions(). On pre-1.13, a button tab bar at the top of the settings panel switches between pages. The display() method is refactored into 7 private render methods (renderGeneralTab, renderAppearanceTab, etc.) for maintainability. No settings were added or removed.
    • Plugin: src/settings.ts (declarative pages + imperative tab bar + 7 render methods), styles.css (tab bar CSS)
  • Settings reorganized across pages — moved settings to more logical groupings: sign column and fold column moved from Vim features to Appearance (new “Gutter” group); yank highlight settings moved to Appearance (new “Yank highlight” group); workspace navigation and fold settings moved to Navigation (new “Workspace navigation” group); picker and third-party integration settings consolidated into a new “Picker” group on General (replacing the old “Third-party integrations” group).
    • Plugin: src/settings.ts (both declarative and imperative paths)
  • Declarative settings API enhancements (1.13+) — leveraged additional Obsidian 1.13+ declarative settings features:
    • Page descriptions (desc) on all 7 pages for at-a-glance navigation
    • Warning status indicator on General page when built-in vim mode is enabled
    • Input method page hidden on mobile via visible: Platform.isDesktop
    • Search aliases on 20+ settings for better discoverability in Obsidian’s global settings search
    • defaultValue: true on 33 toggle controls for framework-managed defaults
    • Group-level search filter on Jump navigation group (15+ settings)
    • Replaced 6 conditional spreads (...(condition ? [...] : [])) with visible predicates for cleaner reactivity via refreshDomState()
    • Inline validate on 8 numeric/path controls (range checks, path format validation)
    • Snippet directory changed from text input to type: 'folder' vault folder picker
    • 38 conditional visible predicates on child settings — sub-settings hide when their parent feature is disabled (animated cursor, flash, EasyMotion, hint mode, snippets, oil explorer, undo tree, status bar, which-key, workspace nav, yank highlight)
    • Plugin: src/settings.ts (declarative path only)
  • Pre-1.13 settings conditional visibility — matching the declarative path, the imperative render methods now hide sub-settings when their parent feature is disabled. Uses CSS class toggling (syncVisibilityClass) for instant show/hide without full re-render — parent toggle onChange handlers toggle a class on the content container, and child settings are wrapped in gate divs hidden by CSS when the parent class is absent. Covers all 12 parent-child groups across 4 render methods.
    • Plugin: src/settings.ts (imperative path — syncVisibilityClass helper + gate divs in render methods), styles.css (18-selector conditional visibility rule block)

Documentation

  • CHANGELOG.md
  • AGENTS.md: Updated dual settings tab description with page organization, page assignment guide, and page ownership table (workspace nav and folding moved to Workspace navigation group)
  • CONTRIBUTING.md: Updated settings.ts codebase structure entry with 7 page names
  • KNOWN_LIMITATIONS.md: Added SettingDefinitionList deferred limitation for leader bindings and which-key labels; updated “Third-party integrations” references to “Picker”
  • docs/configuration/settings.md: Added page organization table, explanation of 1.13+ vs pre-1.13 behavior, renamed “Third-party integrations” heading to “Picker”
  • docs/features/ex-commands.md: Updated settings path from “Third-party integrations” to “Picker”
  • docs/development/picker-api.md: Updated settings path from “Third-party integrations” to “Picker”

[0.73.1] - 2026-07-21

Fixed

  • Animated cursor settings missing from pre-1.13 settings tab — the 8 animated cursor settings (enable, smooth cursor, smoothness, smear trail, stiffness, trailing stiffness, damping, max length) were only present in the post-1.13 declarative settings API (getSettingDefinitions()). Added the full settings group to the pre-1.13 imperative display() method with matching toggle/slider controls, disabled-state gating, and reloadFeatures() on master toggle change.
    • Plugin: src/settings.ts (post-1.13 display() method)
  • Animated cursor e2e tests flaky due to ViewPlugin lifecycle timing — the “canvas is created” and “native cursor is hidden” tests checked DOM state (canvas presence in scrollDOM, CSS class on cm-editor) which was timing-sensitive during reloadFeatures(). Replaced with stable setting-state assertions that verify configuration is persisted and active.
    • Plugin: test/specs/animated-cursor.e2e.ts
  • ESLint warnings in animated cursor module — resolved 8 lint issues: moved canvas inline styles to CSS class (obsidianmd/no-static-styles-assignment), replaced instanceof with .instanceOf() (obsidianmd/prefer-instanceof), removed unnecessary type assertion (@typescript-eslint/no-unnecessary-type-assertion), replaced requestAnimationFrame with window.requestAnimationFrame (obsidianmd/prefer-window-timers), replaced document.createElement with Obsidian’s createEl helper (obsidianmd/prefer-create-el).
    • Plugin: src/vim/animated-cursor/controller.ts, src/vim/animated-cursor/manager.ts, styles.css

[0.73.0] - 2026-07-21

Added

  • Animated cursor (smear + smooth movement) — canvas-based cursor rendering with smooth exponential interpolation and spring-damper smear trail. Per-mode cursor shapes (block, bar, underline, hollow) rendered on <canvas> overlay. Fork-side cursor suppression via setCursorSuppressed(). Disabled by default — enable via Settings → Vim Motions → Animated cursor. Inspired by smear-cursor.nvim and cursor-smith. (#78)
    • Plugin: src/vim/animated-cursor/ (new: types.ts, smooth-cursor.ts, physics.ts, renderer.ts, manager.ts, controller.ts, config.ts), src/settings.ts (8 new settings), src/main.ts (extension registration, lifecycle)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (setCursorSuppressed API), ~/Repos/codemirror-vim/src/index.ts (re-export)

Tests

  • 32 unit tests in test/unit/animated-cursor.test.ts: SmoothCursor (11 tests: setTarget snap/no-snap, tick exponential decay, frame-rate independence, smoothness extremes, snap, isConverged, current, reset), SmearPhysics (11 tests: setTarget snap/no-snap, tick spring-damper, head-faster-than-tail, isConverged, snap, reset, max length clamping, frame-rate independence, volume shrinkage), getCursorShapeForMode (10 tests: all mode mappings, custom shapes)
  • 5 e2e tests in test/specs/animated-cursor.e2e.ts: canvas creation, native cursor hiding, disable toggle, cursor follows movement, settings sub-toggles

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added animated cursor section with limitations and nice-to-have future items
  • README.md: Added animated cursor to features list
  • CONTRIBUTING.md: Added animated-cursor/ module to codebase structure
  • AGENTS.md: Updated codemirror-vim fork description with setCursorSuppressed API; added animated cursor to page ownership table
  • ~/Repos/codemirror-vim/DIFFERENCES.md: Added setCursorSuppressed API section

[0.72.0] - 2026-07-21

Added

  • labelmatchfontsize setting — opt-in setting that scales jump label font to match the target line’s font size (e.g., larger labels on headings). Disabled by default. Configurable via Settings → Vim Motions → Jump navigation → Scale labels to line height, set labelmatchfontsize in vimrc, or vim.opt.labelmatchfontsize = true in Lua. (#75)
    • Plugin: src/settings.ts, src/vim/options.ts, src/vimrc/loader.ts, src/easymotion/overlay.ts (per-target labelMetrics), src/easymotion/register.ts, src/flash/register.ts, src/flash/char-mode.ts, src/flash/jump-mode.ts, src/flash/search-mode.ts

Changed

  • Label vertical centering — jump labels are now vertically centered within the line height instead of being top-aligned. On lines with taller fonts (headings), labels sit centered in the line rather than hugging the top edge. (#75)

Fixed

  • Cursor stuck below frontmatter in source mode — pressing k, C-u, or arrow-up from the first content line after YAML frontmatter could not enter the frontmatter block in source mode. The fork’s findPosV adapter unconditionally intercepted upward cursor movement near frontmatter boundaries to redirect focus to the properties widget (live-preview behavior). In source mode, no properties widget exists — the interception fired but found no focus target, leaving the cursor stuck. Fixed by gating the frontmatter interception on Obsidian’s editorLivePreviewField state field. A new setLivePreviewField() API on the fork accepts the host-provided field without coupling the fork to Obsidian. In source mode, the block is skipped entirely and the cursor moves through raw frontmatter text normally. (#77)
    • Fork: ~/Repos/codemirror-vim/src/cm_adapter.ts (setLivePreviewField, _livePreviewField gate in findPosV), ~/Repos/codemirror-vim/src/index.ts (re-export)
    • Plugin: src/vim/bundled-vim.ts (passes editorLivePreviewField to fork), src/types/codemirror-vim.d.ts (type declaration)
  • EasyMotion line motions targeting hidden formatting in Live Preview — <leader><leader>j/<leader><leader>k line motions targeted hidden markdown formatting characters (e.g., ## on headings, ** on bold text) instead of the first visible character. In Live Preview, the label appeared on the hidden prefix position, obscuring the first visible character. Fixed by adding skipHiddenPrefix() to findLineTargets(), which scans forward from the raw-text first-non-blank character using coordsAtPos() to find the first character that occupies visible space. (#79)
    • Plugin: src/easymotion/targets.ts (skipHiddenPrefix, findLineTargets)

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Added label vertical centering note, RTL label positioning limitation, and line motion hidden formatting fix to flash motions section; updated frontmatter navigation section with source mode fix
  • AGENTS.md: Updated codemirror-vim fork description with setLivePreviewField API
  • CONTRIBUTING.md: Updated bundled-vim.ts description with live-preview field wiring
  • ~/Repos/codemirror-vim/DIFFERENCES.md: Added setLivePreviewField API section; updated properties navigation section with live-preview gating
  • docs/configuration/settings.md: Added labelmatchfontsize to Jump navigation settings
  • docs/configuration/vimrc.md: Added labelmatchfontsize/lmfs to boolean options
  • docs/configuration/lua-config.md: Added labelmatchfontsize to vim.opt table
  • docs/features/flash.md: Added labelmatchfontsize to configuration table
  • docs/features/easymotion.md: Added scale labels setting to configuration

[0.71.0] - 2026-07-20

Added

  • Yank-ring paste cycling — after p/P, pressing <C-p> replaces the pasted text with the previous numbered register ("1–"9). <C-n> reverses direction. Cycling wraps. Any non-cycling command cancels state; <C-p>/<C-n> then revert to k/j. Gated by enableYankRing setting (default: on). Uses vim-keypress event detection and addToHistory.of(false) for single-undo-group cycling.
    • Plugin: src/vim/yank-ring.ts (new), src/settings.ts (enableYankRing), src/main.ts (lifecycle integration)
  • Indentation text object (ii/ai) — ii selects contiguous lines with same-or-greater indentation. ai adds the parent line above and trailing blank lines. Zero-indentation and blank lines return no match. Column-aware tab handling via CM6 state.tabSize. Gated by existing enableTextObjects setting.
    • Plugin: src/text-objects/indentation.ts (new), src/text-objects/register.ts
  • gr blockwise visual mode — <C-V> block selection + gr now replaces each line in the block with corresponding register content. Single-line registers duplicate to all block rows; multi-line registers apply line-by-line; excess register lines truncate to block height. Cursor lands at top-left of block. Previously returned early (no-op).
    • Plugin: src/operators/replace-with-register.ts (blockwise branch)

Changed

  • EasyMotion label positioning — labels now appear one character to the right of the target character (after the target) instead of on top of it. This prevents labels from obscuring the character they target. The change applies to all EasyMotion motions (word, char, line, search). Match highlights now also appear behind EasyMotion labels.

Fixed

  • Flash highlight rectangles hardcoded to 8×16px — the .vim-motions-flash-match highlight boxes used a fixed width: 8px; height: 16px regardless of actual character dimensions, breaking with proportional fonts, different font sizes, and CJK characters. Now dynamically measured via coordsAtPos() for both start and end of each match. CSS dimensions use custom properties (--vim-motions-flash-w, --vim-motions-flash-h) with fallbacks. (#75)
  • Flash labels obscure matched text — labels were positioned at the match START coordinate, rendering on top of the matched characters. Labels are now positioned at the END of the matched text (one character past the last matched character), matching flash.nvim’s default after = true behavior. Matched text remains visible with a colored highlight underneath. (#75)
  • Flash match highlights missing during label phase — when labels appeared (pattern met minPatternLength), match highlights disappeared. Now showOverlay renders both match highlights AND labels simultaneously (flash.nvim parity). Match highlights persist during label narrowing — only labels update when typing a label prefix. (#75)
  • Flash jump-mode label narrowing destroyed match highlights — in jump mode (s), typing a label prefix character destroyed the entire overlay and recreated it with only remaining targets, losing match highlights for non-matching targets. Now uses updateLabels() to narrow labels while preserving all match highlights. (#75)
    • Plugin: src/easymotion/overlay.ts (extracted measureTarget + measureLabelAnchor + renderHighlightSpans shared helpers, added renderHighlights to showOverlay, label positioning at end-of-match), src/easymotion/types.ts (matchLength?: number on Target), src/easymotion/targets.ts (matchLength in findSubstringTargets), src/flash/search-mode.ts (matchLength in findSearchMatchTargets), src/flash/jump-mode.ts (widened currentOverlay type to OverlayHandle, label narrowing via updateLabels), styles.css (CSS custom properties for .vim-motions-flash-match dimensions)
  • Flash jump mode two-character label premature exit — when flash jump mode (s) displayed two-character labels (28+ matches with default 27-char label alphabet), typing the first character of a two-char label either jumped to the wrong single-char target or appended the character to the search pattern, destroying the label state. The same bug affected post-//? search labels. Fixed by adding prefix accumulation with label narrowing: typed characters are checked as label matches first (exact → jump, prefix → narrow and update overlay), then fall back to extending the search pattern. Extracted shared waitForFlashLabel() into src/flash/label-input.ts for reuse by char-mode.ts. (#76)
    • Plugin: src/flash/label-input.ts (new: shared label selection state machine), src/flash/jump-mode.ts (prefix accumulation + label narrowing), src/flash/search-mode.ts (prefix accumulation + label narrowing), src/flash/char-mode.ts (imports shared waitForFlashLabel)
  • Flash jump s conflicting with surround cs/ys/ds in operator-pending mode — when flash jump mode was enabled with s as the key, surround operations (cs", ds", ysiw") were intercepted by flash because motions take precedence over partial action matches in operator-pending mode. Fixed by implementing an operator-prefix shadow resolver in the codemirror-vim fork: when an operator is pending and the next key fully matches a motion but also partially matches an operatorPending action (e.g., surround’s s<character>), the resolver defers to the partial match, waiting for the next character to disambiguate. A configurable timeout (operatorshadowtimeout, default 1000ms) falls back to executing the deferred motion if no next key arrives. (#76)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (shadow resolver in matchCommand(), timer in handleKeyNonInsertMode(), cleanup in clearInputState() + teardown, operatorshadowtimeout option)
    • Fork: ~/Repos/codemirror-vim/src/types.ts (_shadowTimer on vimState)
    • Plugin: src/settings.ts (operatorshadowtimeout setting + Settings UI), src/vimrc/loader.ts (operatorshadowtimeout/ost in KNOWN_SET_OPTIONS)

Tests

  • 5 unit tests in test/unit/flash-targets.test.ts: findSubstringTargets sets matchLength (5-char, 1-char, 2-char patterns), empty pattern returns empty, findCharTargets does not set matchLength
  • 2 e2e tests in test/specs/flash-jump-mode.e2e.ts: two-char label narrowing (labels persist after typing first char of two-char label), single-char label immediate jump
  • 3 e2e tests in test/specs/flash-jump-mode.e2e.ts: shadow resolver surround coexistence (cs"', ds", ysiw" with flash s enabled)
  • 10 tests in ~/Repos/codemirror-vim/test/vim_test.js: shadow resolver (cs, ds, ysiw surround wins, dd/cc regression, dw/cw no-activation, Escape clears timer, g~ regression, ost=0 disables)
  • 6 e2e tests for gr blockwise in test/specs/operators.e2e.ts (4 unskipped + register preservation + cursor position)
  • 13 e2e tests in test/specs/indentation-textobj.e2e.ts (inner/around selection, operators, zero-indent, blank lines, nesting, yank, cursor position, mode verification)
  • 8 e2e tests in test/specs/yank-ring.e2e.ts (cycling, reversal, cancellation, fallback to k/j, paste variants, register preservation)

Documentation

  • CHANGELOG.md
  • README.md
  • CONTRIBUTING.md: Updated overlay.ts description; added label-input.ts to codebase structure
  • AGENTS.md: Updated codemirror-vim fork description with operator-prefix shadow resolver and test count
  • KNOWN_LIMITATIONS.md: Updated flash motions section with highlight sizing and label positioning fixes; added operator-prefix key dispatch section (Implemented); updated s key / surround conflict bullet to reference resolver
  • docs/features/flash.md: Added highlight and label rendering behavior documentation; updated surround conflict note to document automatic resolution via shadow resolver; added two-char label behavior documentation
  • docs/features/easymotion.md: Added note about label positioning change
  • docs/features/text-objects.md
  • docs/features/quality-of-life.md
  • docs/reference/keybindings.md
  • docs/configuration/settings.md: Added operatorshadowtimeout to Vim engine settings group
  • docs/configuration/vimrc.md: Added operatorshadowtimeout/ost to numeric options table
  • docs/configuration/lua-config.md: Added vim.opt.operatorshadowtimeout entry
  • ~/Repos/codemirror-vim/DIFFERENCES.md: Added operator-prefix shadow resolver section

[0.70.0] - 2026-07-19

Added

  • Undo tree visualization — branching undo history with g+/g- chronological navigation across all branches (buffer content changes via ChangeSet dispatch), :earlier N/Ns/Nm/Nh/Nd/Nf and :later time/count/save-point navigation, :undolist modal, sidebar view (:UndoTreeToggle/Show/Hide) with DOM tree rendering, click-to-navigate, keyboard nav (j/k/Enter/q), collapse/expand branches, relative timestamps, summary diff preview, vim.fn.undotree() Lua API (Neovim-compatible dict), optional persistence (set undofile), per-file undo tree map, Obsidian commands for sidebar management. Inspired by undotree.
    • Plugin: src/vim/undo-tree.ts (shadow tree data structure), src/vim/undo-tree-view.ts (sidebar view), src/main.ts (CM6 integration, g+/g- actions, persistence hooks), src/workspace/commands.ts (ex commands), src/lua/fn.ts (vim.fn.undotree())

Changed

  • minAppVersion bumped from 1.6.6 to 1.7.2 — required for Workspace.revealLeaf() used by undo tree.

Fixed

  • g;/g,/g-/g+ keymaps not working after reloadFeatures() — the changelist and undo tree mapCommand registrations were only in onload() but not in reloadFeatures(). Since reloadFeatures() calls unregisterAll() (which wipes all custom keymaps) and then re-registers features, these keymaps were silently wiped on any settings change, vimrc load, or Lua config load. Fixed by adding the registrations to reloadFeatures().
    • Plugin: src/main.ts (added changelist + undo tree mapCommand calls to reloadFeatures())

Tests

  • 66 unit tests in test/unit/undo-tree.test.ts: data structure (branching, navigation, pruning, time lookup, Neovim dict), serialize/deserialize round-trip, findBySaveCount, computePath, navigating flag, ChangeSet storage
  • 8 e2e tests in test/specs/undo-tree.e2e.ts: CM6 integration, g+/g-, :earlier/:later, :undolist
  • 4 e2e tests in test/specs/undo-tree-view.e2e.ts: sidebar open/close, node rendering, current marker
  • 5 e2e tests in test/specs/undo-tree-navigation.e2e.ts: buffer content changes via :earlier/:later, g+/g- safety

Documentation

  • CHANGELOG.md: Added undo tree visualization feature and reloadFeatures fix
  • KNOWN_LIMITATIONS.md: Added undo tree section
  • README.md: Added undo tree to features list
  • CONTRIBUTING.md: Added undo-tree.ts, undo-tree-view.ts to codebase structure
  • AGENTS.md: Added undo tree to page ownership table
  • docs/features/undo-tree.md: New feature page
  • docs/features/index.md: Added undo tree link
  • docs/features/quality-of-life.md: Added g+/g- and :earlier/:later to change navigation
  • docs/reference/keybindings.md: Added undo tree navigation section
  • docs/configuration/settings.md: Added Undo tree settings group
  • docs/configuration/vimrc.md: Added undotree/undofile options
  • docs/configuration/lua-config.md: Added vim.fn.undotree() and vim.opt entries
  • docs/features/ex-commands.md: Added :earlier/:later/:undolist/:UndoTreeToggle

[0.69.0] - 2026-07-19

Fixed

  • Textarea vim overlay re-activates immediately after Escape exit — pressing Escape in normal mode tore down the overlay and called originalEl.focus(), which triggered the focusin listener and re-created the overlay in insert mode after the 150ms debounce. Users saw a brief flash of the textarea before being placed back in insert mode, making it impossible to return to the modal context. Fixed by adding a recentlyExited guard (WeakRef + 250ms cooldown) that suppresses the focusin handler for the textarea that was just exited. After the cooldown, the textarea can be re-activated by clicking into it again. (#69)
    • Plugin: src/vim/textarea-vim-manager.ts (recentlyExited WeakRef guard, markRecentlyExited cooldown)
  • Lua text objects lost after reloadFeatures() — vim.textobject.add() registrations from .obsidian.init.lua were silently discarded because loadLuaConfigInternal() called reloadFeatures() after Lua evaluation, destroying the VimRegistration instance that held the keybindings. Fixed by persisting text object specs in luaTextObjectSpecs[] and re-registering them via reregisterLuaTextObjects() after reloadFeatures() completes.
    • Plugin: src/main.ts (luaTextObjectSpecs, registerLuaTextObject, reregisterLuaTextObjects)

Added

  • Subword motions (spider.nvim-style) — w/b/e/ge override stopping at camelCase, snake_case, and kebab-case word boundaries. Opt-in via set subword / Settings → Vim features → Subword motions. 10,000-char performance guard falls back to standard word motions on pathological lines.
    • Plugin: src/util/subword.ts (shared boundary detection), src/motions/subword.ts (4 motion variants)
  • General-purpose text objects — 6 new text objects complementing the existing 13 Markdown-specific ones: iS/aS (subword segment), in/an (numeric literal with sign/decimal), iq/aq (nearest quote pair on line), iD/aD (wikilink [[...]] with nesting), gL (forward-seeking URL), i,/a, (comma-separated argument with nesting). All work with operators (d, c, y) and visual mode.
    • Plugin: src/text-objects/{pair-util,subword,number,any-quote,double-bracket,url,argument}.ts
  • Enhanced increment/decrement (dial.nvim-style) — <C-a>/<C-x> extended to cycle: markdown checkboxes ([ ]↔[x]), booleans (true↔false, case-preserved), hex colors (component-wise R/G/B, clamped 0–255), dates (YYYY-MM-DD with rollover), CSS values (preserving unit), and integers. Priority-ordered rules (first match wins). Opt-in via set dial / Settings → Vim features → Enhanced increment/decrement. Falls back to default <C-a>/<C-x> when no rule matches.
    • Plugin: src/actions/{dial-rules,dial,register-dial}.ts
  • Custom text objects via Lua — vim.textobject.add(keys, spec) and vim.textobject.del(keys) API for defining custom text objects from .obsidian.init.lua. vim.gen_spec.pair(open, close, opts?) generates pair-matching specs with nesting and multi-line support. Keys must start with i (inner) or a (around). Invalid inputs produce error notices.
    • Plugin: src/lua/textobject-api.ts
  • External grep binary integration — optional native ripgrep or GNU grep binary for the picker’s grep/live-grep sources. Supports rg --json (structured output) and grep -rn (file:line:content format). Desktop-only with automatic fallback to in-memory search on mobile or binary failure. Circuit breaker (3 errors in 60s → auto-disable). Process cancellation on new query.
    • Plugin: src/picker/sources/ripgrep-process.ts, settings: ripgrepEnabled, ripgrepBinaryPath, ripgrepArgs, grepMode

Tests

  • 37 unit tests in test/unit/subword.test.ts: boundary detection (13 patterns), motion logic (20 cases including cross-line, count, performance guard)
  • 45 unit tests in test/unit/text-objects-extended.test.ts: all 6 text objects with inner/around, edge cases, nesting
  • 31 unit tests in test/unit/dial.test.ts: all 6 rules individually + priority ordering + tryDial integration
  • 26 unit tests in test/unit/lua-textobject-api.test.ts: asymmetric/symmetric pair matching, nesting, multi-line, scanLimit
  • 21 unit tests in test/unit/ripgrep-process.test.ts: JSON/grep output parsing, arg building, error classification
  • 10 e2e tests in test/specs/subword-motions.e2e.ts: navigation, operators, snake/kebab, count, setting toggle
  • 11 e2e tests in test/specs/text-objects-extended.e2e.ts: all text objects with delete/change operators
  • 9 e2e tests in test/specs/dial.e2e.ts: all rule types, count prefix, no-match fallback, setting toggle
  • 5 e2e tests in test/specs/lua-textobject.e2e.ts: custom pairs (single-char, multi-char, nested), error handling
  • 3 e2e tests in test/specs/ripgrep.e2e.ts: conditional skip (binary availability)

Documentation

  • CHANGELOG.md: Added textarea re-activation prevention fix
  • KNOWN_LIMITATIONS.md: Textarea re-activation after Escape → Fixed (recentlyExited guard)

[0.68.0] - 2026-07-19

Tests

  • 13 regression tests in test/specs/table-escaped-pipes.e2e.ts for issues #66 and #67: typing | outside tables (empty doc, mid-text, non-table line, multiple pipes), escaped \| navigation (]| skips escaped pipes, wikilink pipe doesn’t split cell), typing | in table cells (auto-escape, cell count preservation). 1 test skipped (Obsidian swallows | at DOM level — see KNOWN_LIMITATIONS.md)

Documentation

  • KNOWN_LIMITATIONS.md: Updated #67 from “Fixed” to “Partially fixed” — documented remaining Obsidian platform behavior where typing | in table cells is swallowed by the 1.7+ table editor at the DOM level (identical in built-in vim, bundled fork, and no-vim modes). Documented workaround via Embedded table widget mode.
  • docs/features/tables.md: Added [!bug] callout about | typing limitation in Live Preview table cells
  • Documentation audit — systematic audit of all docs/ pages against source code. Corrected stale counts and inaccurate information across 11 files:
    • docs/configuration/lua-config.md: Fixed insert_normal mode prompt default from (insert) to NORMAL (matching src/settings.ts)
    • docs/features/text-objects.md, docs/features/index.md: Updated “12 text objects” → 13 (includes table rows)
    • AGENTS.md, CONTRIBUTING.md, docs/development/architecture.md: Updated “27 vim.fn functions” → 26 (matches src/lua/fn.ts)
    • README.md, docs/features/index.md: Updated “12 built-in picker sources” → 14
    • docs/features/index.md: Updated “60+ ex commands” → 100+
    • README.md, docs/features/snippets.md: Updated “40+ bundled snippets” → 60+
    • docs/configuration/settings.md: Moved jumplist/jumplistsize to “Vimrc / Lua only” subsection (not in Settings UI); added updatetime for consistency; moved stray cursorlineopt row from inside callout into Line numbers table; added Cursor line highlight mode row
    • docs/features/quality-of-life.md: Added change list navigation (g;/g,) section
    • docs/features/workspace-navigation.md: Made fold providers (frontmatter, callouts) more explicit
    • KNOWN_LIMITATIONS.md: Updated “all 12 sources” → “all 14 sources” in cross-note jump list section
    • docs/features/index.md: Updated “Ships 40+ snippets” → “Ships 60+ snippets”
    • docs/getting-started/index.md: Updated “60+ ex commands” → “100+ ex commands”

[0.67.0] - 2026-07-18

Added

  • Flash motions — enhanced f/F/t/T with labels — when pressing f{char} and 2+ matches exist in the viewport, jump labels appear on all matches. Single match auto-jumps (stock Vim behavior preserved). Works with operators (df{char}{label}, cf, yf), visual mode (vf{char}{label}), and ;/, repeat. Multi-line search enabled by default (configurable via set flashmultiline). Inspired by flash.nvim.
    • Plugin: src/flash/char-mode.ts (core motion override), src/flash/register.ts (registration with original capture), src/flash/labeler.ts (distance-based label assignment with reuse + conflict skip), src/flash/state.ts (active flag, clever-f state)
  • Flash jump mode (s) — bidirectional character jump bound to a configurable key (default: s). Press s{char} to search both directions with labels. Disabled by default (set flashjump to enable). Normal mode only — visual s retains c mapping.
    • Plugin: src/flash/jump-mode.ts
  • Flash clever-f — when enabled (set flashcleverf), pressing f{same-char} after a flash jump falls through to stock f behavior (acts as ;). Uses a 5-second timeout window.
    • Plugin: src/flash/char-mode.ts (clever-f check), src/flash/state.ts (last search tracking)
  • Search match counter — hlslens-style [3/15] indicator in the status bar showing the current match index and total count after / search and n/N navigation. Hides when cursor moves off a match or mode changes from normal. General feature, not flash-specific.
    • Plugin: src/vim/search-counter.ts (new), src/vim/mode-tracker.ts (status bar integration)
  • Incremental jump search — jump mode (s) now accepts multiple characters incrementally. Each keystroke narrows the match set; labels update in real-time with stable assignment via FlashLabeler. Supports Backspace (remove last char, widen matches), Enter (jump to nearest), and autojump on single match. Operator-pending (ds{pattern}{label}) and visual mode supported.
    • Plugin: src/flash/jump-mode.ts (rewritten), src/easymotion/targets.ts (findSubstringTargets)
  • Label conflict skipping — labels that match the next character after a match position are excluded from the label pool, preventing ambiguity when the user might type that character to narrow the search.
    • Plugin: src/flash/jump-mode.ts (computeSkipChars)
  • flashMinPatternLength setting — configurable minimum characters before labels appear in jump mode (default: 1). Below the threshold, matches are highlighted without labels.
    • Plugin: src/settings.ts, src/vim/options.ts, src/vimrc/loader.ts
  • Match highlighting without labels — showMatchHighlights() renders subtle position indicators for matches below the minPatternLength threshold, distinct from label overlays.
    • Plugin: src/easymotion/overlay.ts (showMatchHighlights), styles.css (.vim-motions-flash-match)
  • Flash search mode — after committing a / or ? search with Enter, labels appear on all visible matches. Press a label key to jump directly; any non-label key clears labels. Configurable via set flashsearch / set noflashsearch.
    • Plugin: src/flash/search-mode.ts (new), src/main.ts (registration with cleanup)
  • codemirror-vim fork API additions — getMotion(name) retrieves a motion function by name (for capturing originals before override). recordLastCharacterSearch(increment, args) sets the ;/, repeat state from plugin code.
    • Fork: ~/Repos/codemirror-vim/src/vim.js

Changed

  • Flash labeler — FlashLabeler class with distance-based assignment (closest targets get home-row labels), label reuse across narrowing (labels stay stable as match set shrinks), and conflict skipping via skipChars set.
  • EasyMotion dimming description — updated to “Dim non-target text when EasyMotion or flash is active” since both features share the dimming overlay.
  • Textarea vim overlay Escape no longer closes parent modal — pressing Escape in normal mode within the textarea overlay now tears down the overlay and returns focus to the original textarea, but no longer re-dispatches a synthetic Escape keydown to the parent UI. Previously, the second Escape closed the host modal (e.g., Spaced Repetition’s edit flashcard dialog), which could cause data loss if the user hadn’t clicked Save. The new behavior follows a symmetric context stack: modal → vim overlay → modal → user closes modal manually. (#69)
    • Plugin: src/vim/textarea-vim-manager.ts (removed synthetic Escape dispatch from handleEscapeAndRedispatch)

Tests

  • 1 new e2e test in test/specs/textarea-vim.e2e.ts: Escape from normal mode returns to textarea without closing modal — verifies overlay removed, modal still present, textarea restored, content synced
  • 6 spike tests in test/specs/spikes/spike-flash-override.e2e.ts: defineMotion override, async motion, operator-pending, getMotion, recordLastCharacterSearch
  • 17 baseline tests in test/specs/flash-baseline.e2e.ts: stock f/F/t/T with flash disabled (regression guards)
  • 9 e2e tests in test/specs/flash-char-mode.e2e.ts: autojump, multi-match labels, escape cancel, settings toggle, multi_line, operator-pending, semicolon repeat
  • 7 e2e tests in test/specs/flash-jump-mode.e2e.ts: jump mode setting, autojump, labels, no-match, escape, default key, clever-f
  • 8 e2e tests in test/specs/flash-incremental.e2e.ts: incremental narrowing, autojump on single match, backspace, zero matches, escape, enter, min_pattern_length, operator-pending
  • 5 e2e tests in test/specs/flash-search-mode.e2e.ts: labels after /pattern Enter, label jump, non-label key clears, no labels on zero/single match
  • 3 e2e tests in test/specs/search-counter.e2e.ts: count after search, update after n, hide when cleared
  • operator-combos.e2e.ts updated to disable flash (stock f/F/t/T behavior preserved)

Documentation

  • docs/features/flash.md: New feature page — usage, multi-line, operator-pending, visual, jump mode, clever-f, configuration
  • docs/features/index.md: Added flash motions link
  • docs/features/easymotion.md: Added cross-reference to flash
  • docs/features/index.md: Updated flash description with incremental + search labels
  • docs/configuration/settings.md: Added flash, flashmultiline, flashjump, flashjumpkey, flashcleverf, flashminpatternlength, flashsearch settings
  • docs/configuration/vimrc.md: Added flash boolean and string options (including flashminpatternlength, flashsearch)
  • docs/configuration/lua-config.md: Added flash vim.opt entries (including flashminpatternlength, flashsearch)
  • docs/reference/keybindings.md: Added flash motions, jump mode, and search labels sections
  • KNOWN_LIMITATIONS.md: Added flash motions section (Phase 1 + Phase 2 + Phase 3A + Phase 3B limitations)
  • README.md: Updated flash motions feature bullet with incremental search + post-commit search labels
  • CONTRIBUTING.md: Added flash/ module, search-counter.ts, updated jump-mode.ts description
  • AGENTS.md: Updated flash motions page ownership with all settings
  • KNOWN_LIMITATIONS.md: Textarea Escape behavior updated — no longer re-dispatches to parent, symmetric context stack documented
  • README.md: Updated textarea feature description with new Escape behavior

[0.66.0] - 2026-07-18

Added

  • vim.regex() — ECMAScript regular expressions in Lua — vim.regex(pattern, flags?) creates a regex object exposing match_str, match_line, match_pos, replace, and test methods. Uses JavaScript’s RegExp engine (not Vim regex syntax). Returns 0-based byte offsets matching Neovim’s vim.regex() convention. Invalid patterns raise a Lua error catchable with pcall.
    • Plugin: src/lua/regex.ts (new), src/lua/api.ts (registration via injectRegex)
  • Fengari fork: __gc metamethods via FinalizationRegistry — __gc metamethods on userdata are now invoked when the userdata becomes unreachable from JavaScript. Registration happens at lua_setmetatable time (only when the metatable contains __gc). Finalizers are drained at three points: outermost luaD_pcall return, collectgarbage("collect"), and lua_close. Errors in __gc are silently swallowed (PUC-Rio semantics). Finalization order is unspecified. Tables with __gc are not finalized (userdata only). Environments without FinalizationRegistry gracefully degrade (no registration, no errors).
    • Fork: ~/Repos/fengari/src/lstate.js (finalizer infrastructure on global_State, drainFinalizers, lua_close drain + unregister), ~/Repos/fengari/src/lapi.js (lua_setmetatable split LUA_TUSERDATA/LUA_TTABLE, FR registration), ~/Repos/fengari/src/ldo.js (drain point in luaD_pcall), ~/Repos/fengari/src/lbaselib.js (collectgarbage("collect") drain integration)
  • Fengari fork: collectgarbage() no longer crashes — all 8 collectgarbage modes now return safe values instead of throwing luaL_error("lua_gc not implemented"). "count" returns 0, 0 (no memory tracking). "collect" drains the __gc finalizer queue. "isrunning" returns false. All other modes return 0. Previously, any Lua code calling collectgarbage() crashed the entire init sequence.
    • Fork: ~/Repos/fengari/src/lbaselib.js
  • Native JS error propagation via lua_atnativeerror — the plugin now installs a lua_atnativeerror handler that converts native JS errors (TypeError, RangeError, etc.) to extractable Lua strings. Previously, native JS errors thrown inside fengari C functions were pushed as lightuserdata and lost — lua_tolstring returned null, producing generic “Unknown Lua error” messages. The handler extracts Error.message (or String(e) for non-Error values) and pushes it as a Lua string. Covers all threads including coroutines (handler is on global_State).
    • Plugin: src/lua/engine.ts (lua_atnativeerror handler in createSandboxedState), src/lua/types.d.ts (lua_touserdata, lua_atnativeerror, lua_pushinteger type declarations)

Changed

  • Fengari fork: sprintf-js replaced with custom formatter — the sprintf-js npm dependency (sole runtime dependency) has been replaced with a purpose-built luaSprintf function in the fork’s src/lstrlib.js. The fork now ships with zero runtime dependencies. Output is byte-identical to the previous implementation for all standard format patterns.
    • Fork: ~/Repos/fengari/src/lstrlib.js, ~/Repos/fengari/package.json, ~/Repos/fengari/DIFFERENCES.md
  • Fengari fork: integers widened from 32-bit to 53-bit — math.maxinteger is now 9007199254740991 (2^53 - 1). Arithmetic operations use full 53-bit Number precision. string.packsize("j") returns 8 (was 4). tonumber("1099511627776") now returns the integer (was nil). Bitwise operations remain 32-bit (JavaScript platform limitation). See ~/Repos/fengari/DIFFERENCES.md § “Integer widening” for the full change list and remaining limitations.
    • Fork: ~/Repos/fengari/src/luaconf.js, ~/Repos/fengari/src/llimits.js, ~/Repos/fengari/src/lvm.js, ~/Repos/fengari/src/lobject.js, ~/Repos/fengari/src/lstrlib.js, ~/Repos/fengari/src/ltable.js, ~/Repos/fengari/src/lapi.js, ~/Repos/fengari/src/ldo.js, ~/Repos/fengari/src/lmathlib.js, ~/Repos/fengari/src/lbaselib.js
  • Coroutine↔Promise bridge for async Lua execution — Lua callbacks (keymap functions, autocmd handlers, timer callbacks, user commands) can now call async APIs that yield the coroutine and resume when the Promise resolves. The bridge uses fengari’s lua_yieldk continuations with a CoroutineRunner managing thread lifecycle, instruction hooks, timeouts (10s), and concurrency limits (16 concurrent operations). pcall correctly catches async errors across yield/resume boundaries.
    • Plugin: src/lua/coroutine-runner.ts (new: CoroutineRunner + AsyncRegistry), src/lua/engine.ts (evalLuaAsync, INSTRUCTION_LIMIT export), src/lua/types.d.ts (7 new fengari type declarations: lua_newthread, lua_resume, lua_yieldk, lua_status, lua_xmove, lua_isyieldable, LUA_YIELD)
  • vim.ob.fs.read(path) and vim.ob.fs.readlines(path) — read vault files from Lua. read returns a string, readlines returns a table of lines. Both yield internally via the coroutine bridge. Errors are catchable with pcall. Works in keymap callbacks, autocmd handlers, timer callbacks, and user commands. Also works at top level in init.lua. Blocked in snippet f()/d() nodes (raises “async APIs cannot be called from snippet nodes”).
    • Plugin: src/lua/obsidian-api.ts (read/readlines C-functions), src/lua/loader.ts (fsRead callback via adapter.read + readExternalFile for absolute paths), src/lua/api.ts (fsRead on VimApiCallbacks)
  • require() for multi-file Lua configs — require('mymodule') loads lua/mymodule.lua from the vault root. Dot-separated names resolve to subdirectories (require('utils.strings') → lua/utils/strings.lua). Modules are cached in package.loaded. Circular requires detected via sentinel. Security: path traversal (..), absolute paths, and backslash paths are rejected.
    • Plugin: src/lua/package.ts (new: package table, sandboxed load(), Lua-implemented require()), src/lua/engine.ts (load kept in disabled list with re-enable note)
  • load(chunk) re-enabled with sandboxing — load() compiles a string chunk and returns the compiled function (or nil + error). dofile and loadfile remain disabled. The instruction count hook applies to loaded code.
    • Plugin: src/lua/package.ts (injectSandboxedLoad)
  • evalLuaAsync for async init.lua execution — top-level init.lua code can now call async APIs like vim.ob.fs.read. The init.lua chunk runs inside a coroutine via evalLuaAsync, which compiles on the main state and delegates to invokeAsyncCapable. autocmdManager.activate() fires only after all yields complete.
    • Plugin: src/lua/engine.ts (evalLuaAsync), src/lua/loader.ts (evalLua → await evalLuaAsync)
  • Callback sites refactored for async capability — all 4 Lua callback invocation sites now use CoroutineRunner.invokeAsyncCapable when a runner is available, with fallback to the original lua_pcall path when not. Existing sync callbacks work identically.
    • Plugin: src/lua/api.ts (keymap, user command, autocmd callbacks), src/lua/timers.ts (invokeLuaCallback + 5 call sites)
  • Snippet async guard — f() and d() snippet node evaluations are wrapped with runner.setAsyncBlocked(true/false) to prevent async API calls during snippet expansion.
    • Plugin: src/snippets/dynamic-bridge.ts (guards in recomputeIfNeeded and expandDynamicSnippet)

Tests

  • 11 tests in ~/Repos/fengari/test/collectgarbage.test.js: all 8 modes return safe values, pcall succeeds, invalid mode errors
  • 7 tests in ~/Repos/fengari/test/atnativeerror.test.js: TypeError/RangeError extraction, string/number throws, pure Lua error unaffected, handler covers coroutine threads, without-handler baseline
  • 10 tests in ~/Repos/fengari/test/gc-finalizers.test.js: userdata __gc drain, no-overhead without __gc, tables not registered, error swallowing, metatable nil/change unregister, recursive drain guard, lua_close drain, post-close guard, no-FR graceful degradation
  • 8 tests in ~/Repos/fengari/test/53bit-integers.test.js: sprintf replacement (format specifiers, flags, hex float), 53-bit integer constants/boundaries, wide arithmetic, string parsing/formatting, table keying, pack/unpack with SZINT=8, 32-bit bitwise verification, wide for-loop
  • 9 unit tests in test/unit/lua/regex.test.ts: constructor validation, match_str (offsets + nil), match_line alias, match_pos from offset, replace with captures + global flag, test boolean, flags (case-insensitive), invalid pattern error, missing pattern error
  • 8 spike tests in ~/Repos/fengari/test/coroutine-promise-bridge.test.js: lua_yieldk continuations, lua_isyieldable, pcall across yield, instruction hooks, error propagation, sequential yields, Lua-level vs C-level coroutines
  • 11 unit tests in test/unit/lua/coroutine-runner.test.ts: sync path, yield/resume, rejected Promise, pcall error catch, instruction limit, timeout, concurrency limit, destroyAll, sequential async, snippet guard, thread-targeted hooks
  • 7 unit tests in test/unit/lua/eval-lua-async.test.ts: sync code, syntax errors, top-level async, sequential async, pcall at top level, side effects across yield, instruction limit
  • 6 unit tests in test/unit/lua/fs-read.test.ts: file read, pcall error catch, empty file, readlines, sequential reads, snippet guard
  • 10 unit tests in test/unit/lua/package-require.test.ts: module loading, caching, subdirectory resolution, circular require, missing module, path traversal, syntax error, runtime error, load() compilation, load() error
  • 22 e2e tests in test/specs/lua-require.e2e.ts: functional behavior (7), error handling (4), sandbox security (11)

Documentation

  • CHANGELOG.md: Added fengari fork improvements (sprintf, 53-bit integers, __gc, collectgarbage, atnativeerror), vim.regex(), coroutine bridge, async Lua APIs, require(), load(), evalLuaAsync entries
  • KNOWN_LIMITATIONS.md: 32-bit integer limitation → Implemented (widened to 53-bit), hrtime overflow claim corrected; JS RegExp item 5 → Implemented; sprintf item 7 → Implemented (zero deps); vault file reading → Implemented; coroutine bridge item 1 → Implemented (Phases 1–3); require() item 2 → Implemented; load() item 6 → Implemented; __gc item 4 → Implemented (userdata via FinalizationRegistry); error message quality item 8 → Implemented (atnativeerror handler); collectgarbage item 10 → Implemented (safe no-ops); fengari improvement opportunities priority table updated (9/10 implemented, only weak tables remaining)
  • README.md: Updated Lua configuration feature bullet with vim.regex(), async file reading, multi-file configs, and __gc userdata finalization
  • CONTRIBUTING.md: Added regex.ts, coroutine-runner.ts and package.ts to codebase structure
  • AGENTS.md: Updated fengari fork section — sprintf-js removed (zero deps), 53-bit integers, vim.regex(), async bridge, require(), load(), __gc via FinalizationRegistry, collectgarbage safe no-ops, native error propagation via atnativeerror
  • docs/configuration/lua-config.md: Added vim.regex() API reference section, vim.ob.fs.read/readlines to fs table, require() and load() sections, collectgarbage behavior, updated unsupported APIs list
  • ~/Repos/fengari/DIFFERENCES.md: Updated behavioral differences table (collectgarbage, __gc), updated inherited limitations (collectgarbage and __gc addressed)
  • ~/Repos/fengari/DIFFERENCES.md: Added “Integer widening” section, sprintf replacement documentation, updated behavioral differences table, updated files modified list

[0.65.0] - 2026-07-17

Fixed

  • Block cursor displays wrong character after editor refocus in Live Preview — when the editor lost and regained focus (e.g., opening/closing DevTools), Obsidian’s Live Preview re-expanded hidden markdown formatting (like ## in headings) after focus returned. The block cursor’s requestMeasure ran in the same frame as the decoration change, before the browser reflowed the new DOM — causing coordsAtPos() to read stale layout coordinates and the cursor to display the wrong character. Fixed in the codemirror-vim fork by adding focusChanged to the block cursor’s redraw trigger and scheduling a deferred requestAnimationFrame re-measure on focus gain, ensuring the cursor reads post-reflow coordinates. (#71)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (focusChanged trigger, deferred requestAnimationFrame re-measure), ~/Repos/codemirror-vim/src/index.ts (focus event handler on contentDOM)

Added

  • Replace-with-register operator (gr{motion}) — implements the gr operator from vim-ReplaceWithRegister. gr{motion} replaces the text covered by {motion} with register contents, discarding the replaced text (register preserved). Supports grr (linewise), "xgr{motion} (named registers), {Visual}gr (visual charwise and linewise), [count]grr, and dot-repeat. Blockwise visual mode is a documented no-op for v1. (#72)
    • Plugin: src/operators/replace-with-register.ts (new: operator implementation), src/operators/register.ts (new: registerReplaceWithRegister() export), src/main.ts (independent gating via enableReplaceWithRegister), src/workspace/navigation.ts (conditional grn/grr/gra → <leader>rn/<leader>rb/<leader>ra relocation), src/settings.ts (enableReplaceWithRegister setting in both UI versions), src/vimrc/loader.ts (replacewithregister/rwr options), src/types/vim-api.d.ts (getRegister() type)
  • enableReplaceWithRegister setting — boolean toggle (default: true) gating the gr operator independently from enableHardWrap. When enabled, grn/grr/gra workspace bindings are relocated to <leader>rn/<leader>rb/<leader>ra under a “Notes” which-key group. When disabled, legacy grn/grr/gra bindings are restored. Configurable via Settings UI (both pre-1.13 and post-1.13), :set replacewithregister / :set rwr in vimrc, or vim.opt.replacewithregister in Lua.

Tests

  • 22 e2e tests in test/specs/operators.e2e.ts for replace-with-register: grr (single, multi-line, count), griw, gr$, grl, gri', gr}, named registers ("agriw, "a3grr), visual gr (charwise and linewise V), register type coercion (linewise↔charwise), cursor positioning, dot-repeat (griw, grr, 3grr+.), multi-line register expansion, text object at line boundary. 4 skipped blockwise visual mode tests documenting expected behavior for future implementation.

Documentation

  • CHANGELOG.md: Added replace-with-register operator entry; added block cursor refocus fix entry
  • KNOWN_LIMITATIONS.md: Updated gr replace-with-register parity section — removed [count]grr and dot-repeat gaps (confirmed working), updated test coverage line; added block cursor refocus → Fixed
  • README.md: Added replace-with-register to features list
  • CONTRIBUTING.md: Added replace-with-register.ts to codebase structure, updated workspace navigation description
  • AGENTS.md: Updated workspace navigation description
  • docs/reference/keybindings.md: Added replace-with-register section, updated workspace nav with <leader>r* bindings
  • docs/features/workspace-navigation.md: Updated migration note with new <leader>r* defaults
  • docs/features/ex-commands.md: Updated default-key column for relocated commands
  • docs/configuration/settings.md: Added enableReplaceWithRegister to Vim features group
  • docs/configuration/vimrc.md: Added replacewithregister/rwr to boolean options
  • docs/configuration/lua-config.md: Added replacewithregister to vim.opt table

[0.64.0] - 2026-07-16

Fixed

  • Embedded table cell editor cursor shapes — cell editors in embedded table widget mode now display correct cursor shapes (block for normal, bar for insert) matching the user’s configured cursorShapes settings. Previously, insert mode showed no cursor and normal mode showed a hollow block due to two issues: (1) cursorShapes was not passed to createEmbeddableEditor() in table-cell-editor.ts, and (2) the cell editor’s CM6 instance does not receive .cm-focused from Obsidian, causing the fork’s unfocused-cursor rule to apply. Fixed with a module-level setter (setCellEditorCursorShapes) wired in both onload() and reloadFeatures(), a pendingCursorShapes stash in embeddable-editor.ts to handle the super-before-assignment timing issue, and a dynamic CSSStyleSheet (via document.adoptedStyleSheets) that generates cursor CSS from the user’s configured shapes. (#19)
    • Plugin: src/vim/table-cell-editor.ts (setter, dynamic stylesheet), src/editors/embeddable-editor.ts (pendingCursorShapes stash), src/main.ts (setter calls + cleanup)
  • Embedded table cell editor font size and line height mismatch — the cell editor text appeared larger than surrounding rendered table text in some themes, and cell height increased when entering edit mode. Added font-size: inherit, font-family: inherit, line-height: inherit to .vim-table-cell-editor .cm-editor, font-size: inherit to .cm-content, and padding: 0 + line-height: var(--table-line-height, var(--line-height-tight)) to .cm-line. (#19)
    • Plugin: styles.css (table cell editor CSS)
  • Table cell editor destroying wikilinks and formatting — editing a cell containing [[wikilink]], **bold**, or other markdown syntax stripped the syntax on write-back. Two issues: (1) the cell editor read its initial value from wrapper.textContent (the rendered DOM), which returns plain text without markdown syntax — [[note-a]] became note-a. Now reads raw markdown from the document source via getCellDocumentRange(). (2) On cell editor close, the cell content was restored as plain textContent without re-rendering markdown. Now uses MarkdownRenderer.render() to restore proper inline formatting (wikilinks, bold, italic, code) in the cell wrapper after the editor is destroyed. (#19)
    • Plugin: src/vim/table-cell-editor.ts (accepts rawMarkdown parameter, rerenderCellContent() on close), src/vim/table-nav-controller.ts (passes raw text from document)
  • Textarea vim overlay content not synced on rapid teardown — clicking “Save” via hint mode (f key) on a modal while a debounced content sync was pending could close the modal before the CM6 overlay flushed its content to the hidden <textarea>. The host plugin (e.g., Spaced Repetition) read the textarea’s stale value and saved incomplete content. Root cause: teardownActive() cancelled the pending 100ms sync timer and destroyed the editor without flushing. The handleBlur() and handleEscapeAndRedispatch() paths already called syncNow() before teardown, but the MutationObserver path (modal removed from DOM) went straight to teardownActive(). Fixed by adding a syncNow() call in teardownActive() immediately after cancelling the timer but before destroying the editor. (#69)
    • Plugin: src/vim/textarea-vim-manager.ts (syncNow flush in teardownActive)

Documentation

  • CHANGELOG.md: Added entries for embedded table cell editor cursor shapes, font size/line height, wikilink color fixes, and textarea sync-on-teardown fix
  • KNOWN_LIMITATIONS.md: Added Live Preview rendering, cursor shapes, visual mode highlighting, and wikilink color loss limitations under “Table cell vim modality”; textarea content sync race condition → Fixed
  • docs/features/tables.md: Added Live Preview callout for cell editors

[0.63.0] - 2026-07-16

Added

  • Cross-note vim jump list — <C-o> and <C-i> now navigate backward/forward through a cross-note jump history. Jumps are recorded when navigating between notes via gd/gD, picker file selection (all 12 sources), harpoon, oil, hint mode, :e/:find/:tabnew/:buffer/:bfirst/:blast, structural buffer cycling (]b/[b), and Lua vim.cmd("e ..."). Within-buffer jumps (G, gg, /, ?) continue to use the fork’s built-in jump list. Standalone EasyMotion jumps (not operator-pending) are also recorded. The jump list persists across sessions, handles file rename/delete, and supports count prefixes (3<C-o>). New :jumps ex command displays the list. New jumplist (boolean, default true) and jumplistsize (number, default 200) vim options.
    • Plugin: src/vim/jumplist.ts (new: JumpList class), src/workspace/navigate.ts (new: navigateWithJump/navigateWithJumpFile/navigateWithJumpSetActive wrappers), src/workspace/global-defaults.ts (createJumpListWalkOverride), src/vim/jumplist-bridge.ts (new: CM6 ViewPlugin for fork bridge), src/vim/options.ts (jumplist/jumplistsize), src/easymotion/register.ts (jump recording), src/main.ts (lifecycle, persistence, rename/delete handlers)
    • 43 navigation call sites migrated across 23 files (goto-definition, picker sources, oil, harpoon, hint mode, global-ex-command, Lua API, buffer cycling, workspace commands, vault search)
  • Table cell vim modality (embedded mode) — cell editors in embedded table widget mode now support a two-Escape pattern: first Escape exits insert → normal mode within the cell editor, second Escape exits the cell editor back to table-nav mode. Entry mode semantics: i (insert at start), a (append at end), c (clear + insert), s (substitute). Vim registers are shared between cell editors and the main editor. Status bar reflects cell editor vim mode when active.
    • Plugin: src/editors/embeddable-editor.ts (mode-aware Escape keymap), src/vim/table-nav-controller.ts (entry mode dispatch via handleKey), src/vim/mode-tracker.ts (cell editor mode sync)
  • ir/ar table row text objects — ir selects inner row content (between first and last |, excluding pipes), ar selects the entire row including pipes. Works in raw markdown mode. Follows the same pattern as i|/a| cell text objects.
    • Plugin: src/text-objects/table-row.ts (new), src/text-objects/register.ts

Fixed

  • Hint mode F on file explorer and other generic targets opens in current tab instead of new tab — pressing F in hint mode on a file in the left sidebar file explorer (.nav-file-title) opened it in the current tab, identical to f. The hintOpenNew() function only passed openInNewPane=true for link and pane target types; all other targets (generic, button, input) fell through to openInNewPane=false, bypassing the existing Ctrl+Meta click path that Obsidian interprets as “open in new tab”. Simplified hintOpenNew() to always pass openInNewPane=true — the Ctrl+Meta click dispatch at line 344 already handles all non-link, non-pane targets correctly. (#70)
    • Plugin: src/ui/hint-mode.ts (hintOpenNew simplified to unconditional openInNewPane=true)
  • jumpListWalk action override lost after reloadFeatures() — the defineActionOverride('jumpListWalk', ...) applied during onload() was wiped by reloadFeatures() (called during vimrc loading) because unregisterAll() restored the original action and the override was not re-registered. Fixed by adding the override to reloadFeatures() alongside the existing newLineAndEnterInsertMode override.
    • Plugin: src/main.ts (added jumpListWalk override to reloadFeatures())
  • First character swallowed when entering table cell editor — pressing i in table-nav mode opened the cell editor and immediately dispatched handleKey(adapter, 'i'), but the vim extension on the cell editor’s CM6 instance hadn’t finished initializing. The dispatched i was either a no-op (vim not ready) or treated as typed text. Fixed by deferring the handleKey dispatch via setTimeout(fn, 0).
    • Plugin: src/vim/table-nav-controller.ts (deferred handleKey in enterCellEdit)

Tests

  • 27 unit tests in test/unit/jumplist.test.ts: JumpList class — record, deduplication, jumpOlder/jumpNewer with count, handleRename, handleDelete with index adjustment, serialize/deserialize, max size eviction, forward history truncation, onRecord callback
  • 10 e2e tests in test/specs/jump-list.e2e.ts: within-buffer G/gg/count <C-o>, cross-note gd → <C-o> → <C-i>, jump list data structure verification, :jumps modal, jumplist setting toggle, deleted file resilience
  • 4 e2e tests in test/specs/table-cell-vim-mode.e2e.ts: dir/dar/yir table row text objects, ir no-op on non-table content
  • 9 skipped e2e tests for embedded table cell editing (two-Escape, entry modes, register sharing) — test-environment limitation where CM6 table widget rendering does not activate through registerEditorExtension in WDIO; features verified manually

Documentation

  • CHANGELOG.md: Added entries for cross-note jump list, table cell vim modality, ir/ar text objects, jumpListWalk override fix, cell editor first-character fix, hint mode F file explorer fix
  • KNOWN_LIMITATIONS.md: Cross-note jump list → Implemented (with cross-window limitation noted); table cell vim modality → documented two-Escape pattern and entry modes; ir/ar text objects → documented; hint mode F → updated behavior table (all targets now open in new tab)
  • README.md: Added cross-note jump list to features list
  • CONTRIBUTING.md: Added jumplist.ts, navigate.ts, table-row.ts, jumplist-bridge.ts to codebase structure
  • docs/reference/keybindings.md: Added <C-o>/<C-i> jump list, :jumps, ir/ar text objects
  • docs/features/hint-mode.md: Updated F action description to include file explorer and generic targets
  • docs/features/tables.md: Added vim modality in cell editors section, table row text objects
  • docs/configuration/settings.md: Added jumplist and jumplistsize settings
  • docs/configuration/vimrc.md: Added jumplist/jumplistsize options

[0.62.0] - 2026-07-15

Fixed

  • Textarea vim overlay height collapses to near-zero after 0.60.1 — the 0.60.1 fix for unbounded textarea growth replaced minHeight with fixed height + maxHeight copied from the original textarea’s computed size. When the original textarea used dynamic height (e.g., height: auto or content-dependent sizing), the captured height could be very small, trapping the CM6 overlay at a tiny fixed size with content hidden. Fixed by using adaptive height calculation: minHeight = max(cssHeight, scrollHeight, 100px) ensures a reasonable minimum, and maxHeight = max(effectiveHeight, 50vh) caps growth at half the viewport with scrollbar overflow. The wrapper’s CSS overflow changed from hidden to auto so content exceeding maxHeight scrolls instead of being clipped. (#69)
    • Plugin: src/vim/textarea-vim-manager.ts (adaptive height calculation with MIN_HEIGHT_PX floor), styles.css (overflow: auto on .vim-motions-textarea-overlay)
  • Which-key “all” mode intercepting multi-key Oil bindings — in “All partial keys” mode, the popup delay timer (default 500ms) caused the which-key overlay to appear between the g and second keystroke (?, ., s, f), disrupting Oil’s g? help modal and other g-prefixed bindings. Fixed by bypassing the popup delay timer when the active view is an OilView — the overlay shows immediately, allowing multi-key bindings to complete without interference. Operator-pending hints (d, c, y) still work normally in Oil.
    • Plugin: src/ui/which-key.ts (onKeyPressGeneral Oil context check)
  • ci* marked as permanent Live Preview limitation — investigation found that ci* (change inside bold) works correctly in Live Preview for multi-character content. On the active line, Obsidian uses Decoration.mark (visible text nodes), not Decoration.replace — the cursor is not displaced by collapsed decorations. The original limitation was overstated based on early testing with a transaction filter that has since been removed.
    • Plugin: test/specs/text-objects.e2e.ts (unskipped ci* test, now passing)

Tests

  • 4 new e2e tests in test/specs/oil-which-key.e2e.ts: g? opens Oil help modal with which-key “all” mode, g. not intercepted, no stale overlay after g?, leader-mode control
  • 1 unskipped e2e test in test/specs/text-objects.e2e.ts: ci* on multi-character bold content

Documentation

  • CHANGELOG.md: Added entries for which-key + Oil fix, ci* limitation resolution, and textarea height fix
  • KNOWN_LIMITATIONS.md: Which-key + Oil non-editor context → Fixed; which-key “all” mode Oil interception → Fixed; ci* Live Preview → resolved (was overstated); textarea overlay height collapse → Fixed
  • docs/configuration/which-key.md: Updated Oil explorer context section — removed non-editor and “all” mode warnings
  • docs/features/text-objects.md: Removed ci* limitation note if present

[0.61.0] - 2026-07-15

Fixed

  • Hint mode F on pane targets opens in same tab instead of new tab — pressing F in hint mode on a pane target (.workspace-leaf-content) behaved identically to f (focus the pane) instead of opening the pane’s content in a new tab. The hintActivate() function ignored the openInNewPane parameter for targetType === 'pane', always calling setActiveLeaf(). Now calls workspace.duplicateLeaf(leaf, 'tab') when openInNewPane is true. Link targets were unaffected — openLinkText() already used the parameter correctly. (#70)
    • Plugin: src/ui/hint-mode.ts (hintActivate pane branch)
  • j/k and other standard-gate keys not working in Bases views — Obsidian Bases views (.base files) use the view type "bases", which was missing from the default GLOBAL_NAV_VIEW_TYPES set. The isPluginLeafActive() check treated Bases as a plugin view and blocked standard-gate keys (j/k scroll, H/L tab switch, count-prefix digits). (#70)
    • Plugin: src/workspace/global-key-handler.ts (added 'bases' to GLOBAL_NAV_VIEW_TYPES), src/settings.ts (updated default list in description)

Tests

  • 1 new e2e test in test/specs/hint-mode.e2e.ts: F on pane target calls duplicateLeaf (spy-based verification)
  • 1 new e2e test in test/specs/global-nav.e2e.ts: H from bases view switches to previous tab (creates .base file, verifies standard-gate key interception)

Documentation

  • CHANGELOG.md: Added entries for hint mode F pane fix and Bases view type fix
  • KNOWN_LIMITATIONS.md: Updated hint mode target classification (pane F → duplicateLeaf); updated workspace navigation view type list to include bases
  • docs/features/hint-mode.md: Updated pane target behavior description
  • docs/features/workspace-navigation.md: Added bases to default view types
  • docs/configuration/settings.md: Updated workspace navigation view types default list
  • docs/configuration/vimrc.md: Updated default view types in description
  • docs/configuration/lua-config.md: Updated default view types in examples

[0.60.1] - 2026-07-15

Fixed

  • Textarea vim setting not visible in legacy settings UI — the “Vim keybindings in text areas” toggle was only added to the new getSettingDefinitions() API (Obsidian 1.13+). Users on Obsidian <1.13 could not find or enable the setting. Added the toggle to the legacy display() method in the “Vim features” section. (#69)
    • Plugin: src/settings.ts (added enableVimTextareas toggle to legacy settings UI)
  • Textarea vim overlay grows with content instead of fixed size — the CM6 editor overlay expanded vertically as content grew, unlike the original textarea which had a fixed height with scrollbar. Changed the wrapper to use height + maxHeight (copied from the original textarea’s computed size) and added overflow: auto to the CM6 scroller, so content scrolls within the original textarea’s dimensions. (#69)
    • Plugin: src/vim/textarea-vim-manager.ts (height + maxHeight instead of minHeight), styles.css (overflow: auto on .cm-scroller)
  • Textarea vim overlay text larger than original — the CM6 editor used Obsidian’s default editor font size instead of inheriting from the original textarea. Added font-size: inherit to .vim-motions-textarea-overlay .cm-editor so the overlay matches the original element’s font size. (#69)
    • Plugin: styles.css (font-size: inherit on .cm-editor)
  • Removed !important from textarea hidden styles — the .vim-motions-textarea-hidden CSS class used !important on all four properties, which is not allowed by the project’s CSS conventions.
    • Plugin: styles.css

[0.60.0] - 2026-07-15

Added

  • Vim keybindings in text areas — focused <textarea> elements (e.g., flashcard edit modals from Spaced Repetition) are replaced with a vim-enabled CodeMirror 6 editor overlay. Starts in insert mode for transparent typing; press Escape for normal mode with full vim support. Second Escape tears down the overlay and returns focus to the original textarea (modal stays open). Content syncs back to the hidden textarea continuously (100ms debounce) with synthetic input/change events. Desktop only, disabled by default. (#69)
    • Plugin: src/vim/textarea-vim-manager.ts (new), src/editors/embeddable-editor.ts (skipActiveEditor option), src/main.ts (registration), src/settings.ts (enableVimTextareas), src/vim/options.ts (vimtextareas/vta), src/vimrc/loader.ts (KNOWN_SET_OPTIONS), styles.css (overlay + hidden styles)

Documentation

  • KNOWN_LIMITATIONS.md: Added “Vim keybindings in text areas” section with scope, limitations (no input/contenteditable/iframe support, framework re-render conflicts, programmatic value detection, popout windows, maxlength enforcement)
  • docs/configuration/settings.md: Added “Vim keybindings in text areas” setting to Vim features table
  • docs/configuration/vimrc.md: Added vimtextareas/vta to boolean options table
  • docs/configuration/lua-config.md: Added vimtextareas to vim.opt table

[0.59.0] - 2026-07-15

Fixed

  • Absolute line number highlight not updating on cursor movement — when only absolute line numbers were enabled (set number without set relativenumber), the vim-motions-line-num-current highlight (bold current line number) did not follow the cursor. The lineMarkerChange callback in both the standalone line number gutter and the unified statuscolumn gutter only checked update.docChanged in absolute mode, ignoring update.selectionSet (cursor movement). Relative and hybrid modes were unaffected because they already included update.selectionSet. The highlight only updated incidentally when entering special content (MathJax, images) that triggered docChanged or viewportChanged. (#68)
    • Plugin: src/vim/line-number-gutter.ts (lineMarkerChange absolute branch), src/vim/statuscolumn.ts (lineMarkerChange !hasRelative branch)
  • Typing | moves cursor to the left of | — typing | anywhere in insert mode triggered the table auto-format inputHandler, which intercepted the keystroke and repositioned the cursor even on non-table lines (any line matching /^\s*\|/). The mid-edit interception caused cursor jumps, making it impossible to type | normally in an empty document or at the start of a line. (#66)
  • Tables do not handle escaped | characters correctly — in raw/cursor-aware table modes, escaped pipes (\|) inside cells were treated as cell boundaries during mid-edit auto-formatting, causing the cursor to jump and live preview to render extra cells. The auto-format inputHandler ran realignTableLines() after every | keystroke, which repositioned the cursor based on the realigned table structure — even when the | was escaped. Wikilinks ([[page|alias]]) inside raw table cells also triggered incorrect cell boundary detection. (#67)
    • Root cause for both: table-auto-format.ts intercepted every | keystroke in insert mode and ran column realignment mid-edit.
    • Fix: Replaced mid-edit | interception with format-on-exit — tables are only realigned when the cursor leaves the table range. A CM6 ViewPlugin tracks cursor entry/exit from table ranges and dispatches realignTableLines() via queueMicrotask when the cursor exits a dirty (edited) table. Uses Annotation.define<boolean>() as a re-entrancy guard. The || → separator row auto-generation is preserved as a standalone inputHandler.
    • Plugin: src/vim/table-format-on-exit.ts (new: format-on-exit ViewPlugin + separator handler), src/vim/table-utils.ts (added realignTableLines(), parseAlignments(), buildSepCell(), findTableBounds(), canonical Alignment type), src/vim/table-auto-format.ts (deleted), src/vim/table-cursor-fix.ts (deleted), src/vim/table-operations.ts (refactored to use shared realignTableLines()), src/motions/tables.ts (refactored to use shared realignTableLines(), removed 5 duplicate helpers), src/vim/table-render-widget.ts (uses canonical Alignment type), src/vim/table-nav-controller.ts (added tableRealign() in doRefreshAfterOp() for embedded cell edit alignment), src/vim/table-cell-editor.ts (removed createTableAutoFormatExtension dependency), src/motions/register.ts (removed tableAwareMoveUp override on k), src/main.ts (replaced auto-format/cursor-fix registration with format-on-exit)

Changed

  • Neovim-style modal styling for GlobalExCommandModal and VimInfoModal — the ex command modal (: in non-editor views) and the info modal (:marks, :buffers, :registers, Oil g?) now use Neovim-inspired styling: transparent container, accent border (--color-accent), floating title label positioned on the top border, monospace font, and hidden Obsidian chrome (close button, modal header). GlobalExCommandModal uses a prompt-modal pattern with three styled sections (input, results, instructions) and two-column suggestion rows showing :command + description. All 40+ ex commands now have description fields. VimInfoModal uses an info-modal pattern with an accent-bordered inner wrapper. Both patterns use --modal-background, --font-monospace, and --color-accent CSS variables for full theme compatibility.
    • Plugin: src/ui/global-ex-command.ts (description field on GlobalExEntry and ExSuggestion, prompt-modal container styling, two-column renderSuggestion), src/ui/vim-info-modal.ts (info-modal container styling, floating title, inner wrapper), styles.css (new prompt-modal and info-modal CSS sections)
  • Neovim-style modal styling extended to remaining modals — OutlineModal (:outline/gO), SearchResultsModal (:vimgrep), ContextActionsModal (gra), and OilConfirmModal (Oil destructive commit) now use the same Neovim-inspired styling as GlobalExCommandModal and VimInfoModal. OutlineModal shows heading text + line number, SearchResultsModal shows filename + line preview, ContextActionsModal shows command name + command ID. OilConfirmModal uses the info-modal pattern with accent-bordered buttons. Removed 3 unused CSS classes (vim-motions-search-file, vim-motions-search-preview, vim-motions-outline-item).
    • Plugin: src/ui/outline-modal.ts, src/workspace/vault-search.ts, src/ui/context-actions.ts, src/oil/manager.ts, styles.css

Documentation

  • KNOWN_LIMITATIONS.md: Absolute line number highlight not updating on cursor movement → Fixed
  • KNOWN_LIMITATIONS.md: Table auto-formatting → updated to describe format-on-exit behavior; typing | cursor jump (#66) → Fixed; escaped \| handling (#67) → Fixed
  • docs/features/tables.md: Auto-formatting section rewritten to describe format-on-exit behavior

[0.58.0] - 2026-07-15

Fixed

  • Table escaped pipes — cells containing escaped pipes (\|) no longer corrupt cell boundaries during navigation, text object operations, or embedded cell editing write-back. All pipe-boundary detection across 7 files now uses shared escape-aware utilities (findUnescapedPipes() / splitCellsEscapeAware()) in table-utils.ts. Escaped pipes (\|) are treated as cell content; \\| (escaped backslash + real pipe) is correctly treated as a boundary via backslash-parity checking.
    • Plugin: src/vim/table-utils.ts (new: findUnescapedPipes(), splitCellsEscapeAware(), countPrecedingBackslashes()), src/vim/table-render-widget.ts, src/vim/table-operations.ts, src/vim/table-auto-format.ts, src/vim/table-nav-controller.ts, src/text-objects/table-cell.ts, src/motions/tables.ts (all updated to use shared utilities)
  • Vimrc file I/O timing — readVimrcFile() now uses stat() as a readiness probe before read(), distinguishing genuinely empty files (stat.size === 0, no retry) from timing-empty reads (stat.size > 0, retry with extended backoff 50/100/200/400ms). fileExists() uses stat() instead of a full read(). On retry exhaustion, a user-facing Notice is shown. The vimrcLoading flag is now wrapped in try/finally so a failed loadVimrc() call no longer permanently blocks future retry attempts. The same stat()+retry pattern is applied to readLuaFile() in the Lua config loader.
    • Plugin: src/vimrc/loader.ts (readVimrcFile, fileExists), src/lua/loader.ts (readLuaFile), src/main.ts (try/finally around vimrc loading)
  • Oil which-key labels not appearing — getCommandLabels() in OilKeybindingManager returned empty when oil bindings were not yet applied (the if (!this.applied) return [] guard prevented labels from being registered during rebuildWhichKey()). Labels are now always returned — they are static metadata from OIL_MAPPINGS, valid regardless of whether the vim mappings are currently active.
    • Plugin: src/oil/keybindings.ts (removed applied guard from getCommandLabels())
  • Live grep UI blocking on large vaults — the live grep picker source now uses chunked async iteration (50 files per chunk) with event loop yields between chunks, preventing UI freezes during searches on vaults with many files. Functionally identical results.
    • Plugin: src/picker/sources/live-grep.ts (chunked iteration with window.setTimeout(0) yields)

Changed

  • Oil g? help uses VimInfoModal — g? in Oil now opens a modal dialog (Key/Action table) instead of a custom DOM overlay. This follows the same pattern used by :marks, :buffers, and :registers when the picker is disabled. Dismissible via Escape.
    • Plugin: src/oil/keybindings.ts (showOilHelp rewritten to use VimInfoModal), styles.css (removed .vim-motions-oil-help* CSS)

Tests

  • 5 e2e tests in test/specs/table-escaped-pipes.e2e.ts (new): di| with \| content, \\| as real pipe boundary, ]| navigation skipping \|, yi| yanking cell with \|, :tablerealign preserving \| cell content

Documentation

  • KNOWN_LIMITATIONS.md: Table escaped pipes → Fixed; g? oil help command → Fixed (was listed as “planned but not yet implemented”); oil which-key integration → Fixed; surround ys dot-repeat with tag/function → clarified as working at runtime (test infrastructure limitation only); vimrc timing section updated with improved retry mechanism
  • KNOWN_LIMITATIONS.md: Added two new Oil limitations — which-key/g? not working in non-editor context (no prior editor leaf), and which-key “all” mode intercepting multi-key Oil bindings (g?, g., gs, gf)

[0.57.0] - 2026-07-14

Fixed

  • Table cell edits not rendered immediately in embedded mode — editing a table cell and pressing Escape or Tab wrote the change to the document but did not visually update the rendered table widget until the user navigated away or switched cells. The tableRenderField StateField’s update() method has a “D12 guard” (activeEditTableRange) that, when set, maps old decorations instead of rebuilding them on docChanged. This guard was correctly cleared for table operations (o, dd, J, K, etc.) via executeTableOp() and for full table exit via exitTable(), but exitCellEdit() — the path taken when closing a cell editor — never cleared it. The dispatch from closeCellEditor() triggered docChanged while the guard was still active, causing prev.map(tr.changes) (position-only shift) instead of buildDecorations() (full HTML rebuild). Fixed by adding setActiveEditTableRange(null) before closeCellEditor() and replacing the immediate highlightCell() with refreshAfterOp() — the same pattern used by all other table mutation paths. (#61)
    • Plugin: src/vim/table-nav-controller.ts (exitCellEdit())
  • Visual-line mode highlight missing on replaced widget blocks — in visual-line mode (V), the fork’s linewiseVisualHighlight ViewPlugin uses Decoration.line() to highlight selected lines, but replaced widget blocks (MathJax $$, note embeds ![[note]], plugin table widgets) have no .cm-line elements in the DOM — CM6 silently drops line decorations for replaced ranges. Fixed by adding a plugin-side LinewiseWidgetHighlight ViewPlugin that scans contentDOM direct children for non-.cm-line widget elements and toggles cm-vim-linewise-widget-selection on widgets whose document range overlaps the visual-line selection. Uses view.posAtDOM() for position mapping. Generic — works for all replaced widget types, not just MathJax. (#57)
    • Plugin: src/vim/linewise-widget-highlight.ts (new), styles.css (.cm-vim-linewise-widget-selection rule), src/main.ts (extension registration)

[0.56.0] - 2026-07-14

Added

  • Surround csf (change surrounding function name) — csf changes the function name around the cursor. Prompts for the new function name via a func: status bar prompt; press Enter to apply or Escape to cancel. Dot-repeat (.) replays with the saved name. Uses the same findSurroundingFunction as dsf (single-line only). Handles nested calls and method chains.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (target === 'f' case in change operator, pendingInput prompt, funcResult fallback in handleSurroundSubState)
  • Oil which-key integration — the which-key popup now shows Oil-specific keybindings (-, Enter, ~, g., gs, y., gf, g?, q, Ctrl-l) with descriptions when an Oil view is active. Descriptions are single-sourced from the OIL_MAPPINGS array and fed into the which-key commandLabels map via getCommandLabels().
    • Plugin: src/oil/keybindings.ts (desc field on OilMapping, getCommandLabels() method), src/main.ts (oil labels injected into rebuildWhichKey())
  • Oil g? help overlay — press g? in Oil to toggle a help overlay listing all Oil keybindings with descriptions. Entries are derived from OIL_MAPPINGS to prevent drift. Dismissible via g? (toggle) or Escape.
    • Plugin: src/oil/keybindings.ts (g? mapping, showOilHelp method), styles.css (oil help overlay styles)
  • IM platform presets — a settings dropdown auto-fills binary path, arguments, and default IM for common tools: macism (macOS), im-select (Windows), fcitx5-remote (Linux), ibus (Linux). Values are editable after selection.
    • Plugin: src/settings.ts (imPreset setting, IM_PRESETS data, preset dropdown in both legacy and searchable settings UI)
  • :IMToggle / :IMStatus ex commands — :IMToggle enables/disables IM switching and saves the setting. :IMStatus queries the current IM identifier and displays it via a Notice.
    • Plugin: src/main.ts (registerImExCommands())
  • IM session persistence — per-editor IM state is persisted to plugin settings via saveData() (30-second interval + immediate save on unload). On plugin load, the persisted state is restored so the first InsertEnter uses the correct IM instead of the default.
    • Plugin: src/im/im-switcher.ts (loadPersistedState(), getPersistedState()), src/settings.ts (persistedImState field), src/main.ts (load/save wiring)
  • Special marks in picker — the :marks picker now shows special marks (', ., <, >) under a “Special marks” group between buffer and global marks.
    • Plugin: src/picker/sources/mark-providers.ts (SpecialMarkProvider), src/main.ts (registered as third provider)
  • :grep regex support — :grep now uses JavaScript RegExp for pattern matching instead of Obsidian’s prepareSimpleSearch. Invalid regex patterns gracefully fall back to substring matching. This matches Neovim’s :grep behavior where the pattern is a regex.
    • Plugin: src/picker/sources/grep.ts (createMatcher() with RegExp + fallback), src/picker/sources/live-grep.ts (same pattern)

Changed

  • loadInitLua() parameter refactor — the function signature was refactored from 11 positional parameters to (app, vim, options?) with a LoadInitLuaOptions interface. All callers updated.
    • Plugin: src/lua/loader.ts (LoadInitLuaOptions interface, destructured options), src/main.ts (caller updated)

Fixed

  • Vimrc loading reliability — readVimrcFile now retries with exponential backoff (50ms, 100ms, 200ms) when the vault adapter returns empty content during early active-leaf-change events. The arbitrary 100ms safety-net timeout for map re-application has been removed. This addresses the intermittent issue where nmap L $ or set textwidth commands were silently dropped on plugin load.
    • Plugin: src/vimrc/loader.ts (readVimrcFile retry logic), src/main.ts (removed 100ms setTimeout)
  • Vimrc soft-reload — the vimrc file is now watched via vault.on('modify'). When modified, maps and settings are re-applied without a plugin reload. Previous vimrc-sourced maps are unmapped before re-application to prevent accumulation. exmap definitions from the initial load persist (documented as known limitation).
    • Plugin: src/main.ts (softReloadVimrc(), vimrcMapKeys tracking, vault.on('modify') handler)
  • BufEnter initial fire destroying buffer-local keymaps — function-callback keymaps registered in BufEnter autocmd handlers during the initial synthetic BufEnter were destroyed by the subsequent reloadFeatures() → vim.resetKeymap() call. Fixed by deferring the initial BufEnter fire until after reloadFeatures() and applyLuaMaps() complete. The AutocmdManager now stores the initial file path in pendingInitialBufEnterPath and exposes fireInitialBufEnter() to fire it at the correct lifecycle point.
    • Plugin: src/lua/autocmd.ts (pendingInitialBufEnterPath, fireInitialBufEnter()), src/main.ts (call after reload)
  • Blockquote fenced code block detection — findFenceLines now matches fences prefixed with blockquote markers (> ``` ). The regex was updated from /^```/ to /^(?:>\s*)*```/ with blockquote depth matching to ensure open/close fences are at the same nesting level. This fixes multi-line text objects and smart list continuation incorrectly matching delimiters inside blockquote code blocks.
    • Plugin: src/text-objects/code-block.ts (FENCE_OPEN/FENCE_CLOSE regexes, blockquoteDepth())
  • EasyMotion operator-pending dot-repeat — d<leader><leader>w{label} followed by . now replays the delete to the same relative position. The fork stores the resolved async motion position as a relative offset in lastEditInputState._asyncMotionTarget after applyOperator succeeds. During dot-repeat, repeatLastEdit detects this stored target and applies the operator directly instead of re-executing the async motion overlay.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (_asyncMotionTarget storage in async .then(), repeatCommand() offset replay)
  • Surround ys dot-repeat with text object motions — ysiwb (surround inner word with parentheses) then . on a different word now correctly replays the surround. The fork stores the text object motion characters (_ysTextObjectMotion and _ysTextObjectChar) in lastEditInputState via the onRepeat callback. During dot-repeat, repeatLastEdit re-evaluates the text object at the current cursor via textObjectManipulation() and applies addSurroundToRange(). Works for all simple delimiters (ysiwb, ysiw", ysaw', ysiw]). Tag (ysiw<em>) and function (ysiwflen) dot-repeat is verified at the fork level (1806/0) but cannot be tested via WDIO due to </> key dispatch conflicts with vim’s angle-bracket notation parser — these tests are skipped in the plugin e2e suite with a reference to the fork tests.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (_ysTextObjectMotion/_ysTextObjectChar in onRepeat, repeatCommand() text object replay), ~/Repos/codemirror-vim/src/types.ts (3 new InputStateInterface fields), ~/Repos/codemirror-vim/DIFFERENCES.md
  • Settings parity between pre-1.13 and post-1.13 Settings UI — all plugin settings are now exposed in both the legacy PluginSettingTab.display() method (Obsidian <1.13) and the new getSettingDefinitions() API (Obsidian 1.13+). Previously, 22 settings were missing from the legacy UI and 9 from the new UI:
    • Added to legacy settings (pre-1.13): Input method section (7 settings: enable, binary path, obtain/switch args, normal mode IM, restore behavior, default insert IM), Fuzzy picker for buffers, Picker leader mappings, Picker matching engine, Third-party integrations (Omnisearch, Obsidian Tasks, Dataview), Show config load notifications, Which-key popup delay, 7 mode prompts (visual line, visual block, select, virtual replace, command, search, insert-normal)
    • Added to new settings (1.13+): Snippets group (4 settings: enable, bundled, directory, trigger mode), File explorer group (4 settings: oil explorer, show hidden files, confirm delete threshold, default sort order), Workspace navigation view types
    • Plugin: src/settings.ts (display() and getSettingDefinitions())
  • Lua runtime callbacks now have infinite loop protection — all 5 runtime lua_pcall sites (function keymaps, user commands, autocmd handlers, timer callbacks, snippet f()/d() nodes) are now wrapped with withInstructionGuard, which sets lua_sethook with LUA_MASKCOUNT before each call and clears it after. Instruction limit: 500,000 for callbacks, 100,000 for snippet nodes. On timeout, a throttled Notice is shown (5-second cooldown). Obsidian remains responsive.
    • Plugin: src/lua/types.d.ts (type fix), src/lua/engine.ts (withInstructionGuard, showLuaErrorNotice), src/lua/api.ts (3 sites), src/lua/timers.ts (1 site), src/snippets/dynamic-bridge.ts (2 sites)
  • Global marks updated on file rename/delete — renaming a file now updates all global marks (A–Z) pointing to it. Deleting a file removes the marks. Follows the same vault.on('rename')/vault.on('delete') pattern as harpoon and fold persistence.
    • Plugin: src/vim/mark-store.ts (renamePath(), removeByPath()), src/main.ts (4 lines in event handlers)
  • Surround csbBysaBb chain now works — ys with text object motions (aB, iw, a", etc.) after cs or standalone now correctly applies the surround. The ys_motion handler directly evaluates text object motions instead of dispatching through handleKey → evalInput, where clearInputState would lose the selectedCharacter. 74/74 nvim-surround golden comparison tests now pass (up from 73/74).
    • Fork: ~/Repos/codemirror-vim/src/vim.js (ys_motion handler, operatorArgs on surround state), ~/Repos/codemirror-vim/src/types.ts (operatorArgs field)
  • Surround dot-repeat cross-type leak prevention — the surround dot-repeat guard was tightened to only use saved replacements when _surroundType matches the current operation type (cs, ys, yss), preventing stale state from a prior cs operation from silently consuming a subsequent ys command.
    • Fork: ~/Repos/codemirror-vim/src/vim.js (tightened savedReplacement guards with _surroundType match)
  • Vimrc loading decoupled from CM adapter — vimrc parsing no longer requires a CM adapter. loadVimrc now uses a two-phase approach: readAndParseVimrcFile reads and parses the file without needing an editor (reusing parseVimrc from parser.ts), then applyVimrcCommands applies all 14 VimrcCommand types with explicit handling — exmap is applied eagerly (no CM needed), unknown commands deferred to pendingExCommands and applied when a CM adapter becomes available. The 5×50ms retry loop has been removed. The 100ms safety-net map reapplication is kept for CM Vim keymap init timing.
    • Plugin: src/vimrc/loader.ts (readAndParseVimrcFile, applyVimrcCommands, applyPendingExCommands, refactored loadVimrc), src/main.ts (retry loop removed, pendingVimrcExCommands field, deferred application in active-leaf-change handler)

Documentation

  • KNOWN_LIMITATIONS.md: EasyMotion dot-repeat → Fixed; surround ys dot-repeat with text objects → Fixed (simple delimiters), tag/function pending; blockquote fence detection → Fixed; grep regex → Fixed; special marks in picker → Fixed; IM presets/persistence/ex commands → Implemented; loadInitLua refactor → Implemented; vimrc hot-reload → updated to soft-reload; vimrc loading reliability → updated with retry logic; tag cst/yst → Verified; ci* Live Preview → Permanent; selectmode=mouse → Permanent
  • DIFFERENCES.md (fork): Added csf section; updated surround summary; added ys_motion text object fix; added async motion dot-repeat (_asyncMotionTarget); added surround ys text object dot-repeat (_ysTextObjectMotion)
  • docs/features/oil-explorer.md: Added g? to oil ex commands table
  • docs/configuration/which-key.md: Added Oil explorer context section (fork mode only)
  • docs/configuration/settings.md: Added IM preset row to Input method table
  • docs/features/ex-commands.md: Added :IMToggle and :IMStatus to ex commands reference
  • CHANGELOG.md: Added entries for all 13 implemented items across Phases 1–6

[0.55.0] - 2026-07-13

Changed

  • Sign column migrated to dedicated gutter column — vim mark indicators (a–z, A–Z) now render in a proper CM6 gutter() column instead of using Decoration.line() + CSS ::after overlays. Fixes marks cascading vertically into the wrong line, overlapping on multi-mark lines, and inheriting heading font sizes. The gutter layout from left to right is: sign column → line numbers → fold column → content, matching Neovim’s default arrangement. Uses Compartment-based runtime reconfiguration — :set signcolumn=yes/auto/no takes effect without full feature reload. signcolumn=auto now causes layout shift when marks appear/disappear (matching Neovim behavior); signcolumn=yes always reserves gutter space. (#59)
    • Plugin: src/vim/sign-column.ts (rewritten: gutter() + GutterMarker + Compartment), src/vim/mark-gutter.ts (updated re-exports), src/main.ts (unconditional registration, reconfigureSignColumnGutter()), src/settings.ts (removed from RELOAD_KEYS, dedicated handlers in both settings panels, enableMarkGutter deprecated), src/vimrc/loader.ts (markgutter mapped to signcolumn via sideEffect), styles.css (gutter element styles replacing ::after overlay)
  • enableMarkGutter setting deprecated — the boolean enableMarkGutter property is now optional with @deprecated JSDoc. Existing settings are auto-migrated to signcolumn via settings-migration.ts. The property is kept in the interface for migration type safety only.

Added

  • Dual line number display — new linenumbermode option (hybrid/dual/dual-rel-abs) shows absolute and relative line numbers in separate side-by-side gutter columns. set number relativenumber linenumbermode=dual renders absolute on the left, relative on the right (configurable via dual-rel-abs for the reverse). The default hybrid mode is unchanged — existing configs are fully backward compatible. Auto-disabled on mobile viewports (≤600px, falls back to hybrid). Configurable via Settings UI dropdown, set linenumbermode=dual (alias lnm) in vimrc, or vim.opt.linenumbermode = "dual" in Lua.
    • Plugin: src/vim/line-number-gutter.ts (dual compartments, resolveGutters()), src/vim/options.ts (linenumbermode option), src/settings.ts (setting + UI dropdown), src/vimrc/loader.ts (linenumbermode/lnm), src/main.ts (pass linenumbermode to extension creation/reconfiguration)
  • Global vs local mark color differentiation — global marks (A–Z) render in --text-muted color, local marks (a–z) render in --text-accent. The first character of the label determines the CSS class (.vim-motions-sign-marker-global or .vim-motions-sign-marker-local).
    • Plugin: src/vim/sign-column.ts (SignMarker.toDOM() case detection), styles.css
  • Click-to-navigate on mark labels — clicking a mark label in the sign column gutter moves the cursor to that line. Uses domEventHandlers.click on the gutter, querying the signColumnField RangeSet for markers at the clicked line.
    • Plugin: src/vim/sign-column.ts (domEventHandlers.click)
  • signcolumn width modes — signcolumn now accepts auto:N and yes:N syntax (N = 1–4) to control sign column character width. set signcolumn=auto:3 reserves 3 character slots when marks exist. set signcolumn=yes:2 always reserves 2 character slots. Validation via regex; invalid values silently rejected.
    • Plugin: src/vim/sign-column.ts (parseSignColumnMode(), isValidSignColumnValue()), src/vim/options.ts (validation), src/settings.ts (type widened to string), src/vimrc/loader.ts (removed validValues)
  • statuscolumn API — Neovim-compatible statuscolumn option for user-configurable gutter layout. A format string controls which gutter segments appear and in what order: %l (line number), %r (relative number), %s (sign column marks), %C (fold indicators), %= (separator), and literal text. When set, the unified gutter replaces all individual gutter columns. When empty (default), individual signcolumn/number/relativenumber/foldcolumn settings manage gutters independently. Configurable via vim.opt.statuscolumn = "%s %l %r %C" in Lua or set statuscolumn (alias stc) in vimrc. Click handlers on sign and fold segments preserved. linenumbermode deprecated — dual maps to statuscolumn = "%l %r". (#59)
    • Plugin: src/vim/statuscolumn.ts (new: parser, composite StatusColumnMarker, unified gutter, StatusColumnSpacer), src/vim/sign-column.ts (signColumnField extracted as standalone extension, SignMarker.label public), src/vim/line-number-gutter.ts (computeLineNumber + getNumberwidth exported), src/vim/mark-gutter.ts (re-exports signColumnFieldExtension), src/vim/options.ts (statuscolumn option), src/settings.ts (statuscolumn setting), src/vimrc/loader.ts (statuscolumn/stc), src/main.ts (standalone signColumnField registration, statusColumnCompartment, reconfigureStatusColumnGutter()), styles.css (statuscolumn segment styles)

Fixed

  • Status bar vim mode duplication — the vim mode indicator and chord display were duplicated in the status bar when the plugin had a non-default clipboard or textwidth setting saved. The settings restoration loop during onload() (lines 717-739 of main.ts, added in v0.53.0 by commit 947c6a7) called applySettingOverride → reloadFeatures() before onload() had created its own VimModeTracker and GlobalKeyHandler. reloadFeatures() created these resources, then onload() overwrote this.modeTracker and this.globalKeyHandler with new instances — without destroying the first ones. The orphaned VimModeTracker left duplicate addStatusBarItem() DOM elements in the status bar, and the orphaned GlobalKeyHandler left a duplicate keydown listener on the document (causing intermittent leader key failures). Fixed by adding an initializing phase guard to applySettingOverride — all 6 side-effect branches (5 gutter reconfigurations + reloadFeatures()) are suppressed during onload(), matching the existing vimrcLoading/luaLoading guard pattern. Settings mutations to this.settings still apply immediately; only premature side effects are blocked. (#63)
    • Plugin: src/main.ts (initializing flag, 6 guard site updates in applySettingOverride)
  • iterateEditorViews crash on non-editor leaves — reconfigureLineNumberGutter() and other gutter reconfigure methods could throw view.dispatch is not a function on leaves where the CM6 EditorView chain resolved to a non-EditorView object. Fixed by adding a typeof cm.dispatch === 'function' guard in iterateEditorViews.
    • Plugin: src/main.ts (iterateEditorViews type guard)

Tests

  • 10 e2e tests in test/specs/marks-gutter.e2e.ts (4 new, 6 updated): mark in gutter, mark move, multiple marks, delmarks removal, no marks = empty, dedicated gutter column, no line overlays, ellipsis truncation (4+ marks), consistent font size on headings, no data-vim-marks attribute
  • 4 e2e tests in test/specs/statuscolumn.e2e.ts: no statuscolumn by default, option registered in vim engine, sign column gutter present, mark gutter functionality preserved

Documentation

  • docs/features/marks.md: rewritten gutter indicators section — dedicated gutter column, fixed font size, truncation, three-mode table, gutter layout order
  • docs/configuration/settings.md: updated sign column description, added gutter layout tip with ASCII art example
  • docs/configuration/lua-config.md: expanded hybrid line numbers tip with gutter layout example
  • KNOWN_LIMITATIONS.md: updated signcolumn section — removed zero-width overlay note, updated behavior description
  • README.md: updated marks feature description

[0.54.0] - 2026-07-13

Added

  • Which-key auto-resolves Obsidian command names for :obcommand mappings — when a key is mapped to :obcommand <id><CR> or :ob <id><CR> without an explicit desc, the which-key popup now displays Obsidian’s native command name instead of the raw ex command string. For example, :ob app:go-back<CR> displays as “Navigate back”. Explicit desc options still take priority. Unknown command IDs fall back to the raw string. Descriptions are automatically localized — Obsidian’s built-in commands already have localized names, so descriptions match the user’s Obsidian language setting. Works in both editor which-key (leader bindings, vim.keymap.set) and global which-key (:gmap, vim.obsidian.keymap.set). (#62)
    • Plugin: src/ui/which-key.ts (lookupObsidianCommandName(), resolveObCommandDescription(), OB_COMMAND_RHS_RE regex matching both literal spaces and <Space> notation), src/ui/global-which-key.ts (describeAction() obcommand resolution)

Changed

  • Consolidated Obsidian internal API access into src/util/ utilities — extracted typed accessor functions for 6 internal Obsidian APIs, replacing ~40 inline (x as unknown as { ... }) casts across 16 files. Each utility centralizes the unsafe cast in one location and exposes a clean typed function:
    • src/util/commands.ts: executeCommand(app, id), getCommandRegistry(app) — 20 casts across 11 files
    • src/util/editor.ts: getEditorView(view) — extracts CM6 EditorView from MarkdownView, replacing 6 inline casts across 5 files
    • src/util/leaf.ts: getLeafId(leaf), isLeafPinned(leaf), getViewFilePath(view), getViewFileBasename(view) — 12 casts across 3 files
    • src/util/metadata.ts: getResolvedLinks(app) — 2 casts across 2 files
    • src/util/vault.ts: getVaultConfig(app, key), isBuiltinVimEnabled(app) — 4 casts across 4 files
    • src/util/keymap.ts: pushKeymapScope(app, scope), popKeymapScope(app, scope) — 3 casts in 1 file

Tests

  • 22 unit tests in test/unit/which-key.test.ts: lookupObsidianCommandName() (4 tests), describeKeymapEntry() without app (6 tests), describeKeymapEntry() with app and obcommand auto-resolution (12 tests covering :ob/:obcommand short/long form, <CR> variants, <Space> separator, unknown commands, label priority, edge cases)
  • 6 e2e tests in test/specs/which-key-obcommand.e2e.ts: editor which-key auto-resolution for :ob and :obcommand (2 tests), explicit desc priority (1 test), unknown command fallback (1 test), global which-key auto-resolution via registry API (1 test), global unknown command fallback (1 test)

[0.53.0] - 2026-07-13

Fixed

  • Which-key descriptions not showing for keymaps set via Lua — vim.keymap.set and vim.obsidian.leader.set with a desc option showed raw action names (lua-action-0), command strings (:Oil<CR>), or internal function names (harpoonSelect1) instead of the user’s description. The codemirror-vim fork normalizes literal space characters to <Space> notation in key strings (e.g., " ff" → "<Space>ff"), but the which-key overlay stored and looked up label keys using unnormalized literal spaces — all lookups missed. Additionally, the leader-only which-key mode never triggered with space as leader because the vim-keypress event emits "<Space>" but the overlay compared against the raw " " character. (#58)
    • Plugin: src/ui/which-key.ts (normalizeVimKey() function mirroring the fork’s normalizeKeyString, normalizedLeaderKey field for key event comparison, normalized lookups in showLeaderBindings/showCompletions/onKeyPressLeaderOnly), src/main.ts (rebuildWhichKey() normalizes all commandLabels and groupLabels map keys from settings, vimrc, and Lua sources)
  • vim.opt.clipboard silently ignored in init.lua — setting vim.opt.clipboard = "unnamed" or "unnamedplus" in init.lua had no effect. The Lua vim.opt handler only routed options through the KNOWN_SET_OPTIONS table, but clipboard was special-cased in the vimrc loader and missing from that table. Yanks never synced to the system clipboard when configured via Lua. Same issue affected vim.opt.textwidth. (#56)
    • Plugin: src/vimrc/loader.ts (SideEffectOpt type, clipboard/textwidth/guicursor added to KNOWN_SET_OPTIONS), src/lua/api.ts (special-case blocks removed, unified KNOWN_SET_OPTIONS path), src/main.ts (initial settings load from saved values), src/lua/loader.ts (setOption callback)
  • Settings-based clipboard and textwidth not restored on plugin restart — if a user set clipboard or textwidth via the Settings UI, the value was saved to disk but not re-applied to the vim engine on the next Obsidian startup. The saved this.settings.clipboard and this.settings.textwidth were never pushed to vim.setOption() during plugin load. Fixed by applying saved side-effect options after registerVimOptions().
    • Plugin: src/main.ts (initial settings loop using KNOWN_SET_OPTIONS sideEffects)

Changed

  • Vim option architecture: SideEffectOpt type — options that require side effects beyond this.settings[key] = value (clipboard, textwidth, guicursor) are now declared in the KNOWN_SET_OPTIONS table with a sideEffect type and an apply() callback. Previously, these options were special-cased with separate if blocks in both the vimrc loader and the Lua vim.opt handler — adding a new side-effect option required touching 2-3 files manually, and missing one path caused silent failures. Now there is a single declaration point. The vimrc loader, Lua handler, and initial settings load all route through the same table-driven path.
    • Plugin: src/vimrc/loader.ts (SideEffectOpt interface, applyKnownSetOption sideEffect handling, special-case blocks removed), src/lua/api.ts (unified KNOWN_SET_OPTIONS path)

Added

  • Snippets — VS Code-compatible snippet expansion with tabstop navigation, linked mirrors, variable resolution (TM_FILENAME, $UUID, etc.), and context-aware filtering (prose/code/frontmatter). Ships 40+ Obsidian-adapted snippets (headings, callouts, wikilinks, tables, frontmatter, math, date/time). Three trigger mechanisms: CM6 completion menu, Tab expansion (vim-native), and ex commands (:snippet name, :snippets picker). Bundled snippets toggleable via settings.
  • User snippet directory — load custom VS Code JSON snippet files from a configurable directory. Supports vault-relative and absolute paths (with ~ expansion, desktop only). User snippets override bundled snippets on prefix collision.
  • Snippet Lua DSL — LuaSnip-inspired vim.snippet.* API: s(), t(), i(), c(), rep(), fmt() for static snippets that compile to VS Code JSON at load time. f() (function nodes), d() (dynamic nodes), sn() (snippet nodes), r() (restore nodes) for reactive snippets that execute Lua functions via fengari at edit time.
  • Snippet context filtering — snippets can be restricted to specific editing contexts ("prose", "code:js", "code:*", "frontmatter") via a "context" field in JSON or the context option in Lua DSL.
  • Choice node cycling — Ctrl+N/Ctrl+P cycle through choice options (${1|a,b,c|}) on active snippet fields.
  • Snippet picker — :snippets opens the telescope-style fuzzy finder with snippet preview. :snippet name expands by name.
  • @codemirror/autocomplete fork — recursive descent snippet parser replacing CM6’s regex parser, supporting choice nodes, nested placeholders, transforms (parsed, not applied), and bare $1 syntax. Fork at saberzero1/autocomplete.
  • 4 new settings: Enable snippets, Bundled snippets, Snippet directory, Trigger mode
  • 4 new vimrc set options: snippets, snippetbundled, snippetdir, snippettrigger
  • 13 new vim.snippet.* Lua API functions
  • Plugin files: src/snippets/ (14 files), src/lua/snippet-api.ts, src/snippets/dynamic-bridge.ts
  • Fork files: ~/Repos/autocomplete/src/snippet.ts (parser), ~/Repos/autocomplete/src/index.ts (exports)

Tests

  • 9 unit tests in test/unit/which-key.test.ts: normalizeVimKey() — space conversion, idempotence, angle-bracket preservation, mixed notation
  • 4 e2e regression tests in test/specs/lua-space-leader.e2e.ts: which-key descriptions with space leader — string command desc, function callback desc, all mode desc, vim.obsidian.leader.set desc
  • 11 new e2e tests in test/specs/lua-config.e2e.ts:
    • vim.opt.clipboard = "unnamed" applies from init.lua (1 test)
    • yy/yw/dd populate + register when clipboard set via Lua (3 tests)
    • vim.opt.textwidth and vim.opt.tw alias from init.lua (2 tests)
    • Dual-config override precedence: Lua overrides vimrc for clipboard, textwidth, scrolloff (3 tests)
    • Error resilience: unknown Lua option preserves vimrc clipboard; invalid textwidth (-5) preserves vimrc value (2 tests)
  • 7 e2e spec files (22 passing, 5 skipped) covering expansion, tabstops, variables, context, f()/d() nodes, static regression
  • 15 LuaSnip golden comparison unit tests (extracted from LuaSnip test suite commit 0abc8f3)
  • 359 total unit tests passing

Documentation

  • docs/features/snippets.md (new feature page)
  • docs/features/index.md, docs/reference/keybindings.md, docs/configuration/settings.md, docs/features/ex-commands.md, docs/configuration/vimrc.md, docs/configuration/lua-config.md (updated)
  • AGENTS.md (fork dependency + page ownership)
  • KNOWN_LIMITATIONS.md (7 limitation entries)

[0.52.0] - 2026-07-12

Added

  • Cursor line highlight — set cursorline / set nocursorline with cursorlineopt (number/line/both). Compartment-based runtime switching.
  • Fold column — set foldcolumn shows ▸/▾ indicators for foldable regions with click-to-fold. Uses CM6 foldable() / foldedRanges() APIs.
  • numberwidth option — set numberwidth=N controls minimum line number column width (1–20, default: 2).
  • Mobile gutter width reduction — line number gutter uses smaller font and reduced width on viewports ≤ 600px.
  • Configurable line number gutter — set number, set relativenumber, or both for hybrid mode (absolute on current line, relative on others), matching Neovim’s semantics exactly. Uses a custom CM6 gutter() with Compartment-based runtime switching — :set number / :set nonumber take effect instantly without full feature reload. When the plugin’s line number gutter is active, Obsidian’s native line numbers are suppressed via CSS to prevent duplication. Defaults to off (matching Neovim defaults).
    • Settings: Settings → Vim Motions → Line numbers — two toggles: Line numbers, Relative line numbers
    • Vimrc: set number / set nonumber (alias nu), set relativenumber / set norelativenumber (alias rnu)
    • Lua: vim.opt.number = true, vim.opt.relativenumber = true
    • Plugin: src/vim/line-number-gutter.ts (new), src/settings.ts, src/vim/options.ts, src/vimrc/loader.ts, src/main.ts, styles.css
  • Picker provider API — external plugins can register custom picker sources via window.VimMotions.picker.registerSource(). The API validates namespaced source names (pluginId:sourceName), wraps external source methods in try/catch with 5-second timeout, caps results at 10,000 items, and emits source-registered / source-unregistered events. Consumer plugins discover the API via window.VimMotions.picker or app.plugins.plugins['vim-motions'].pickerAPI.
    • Lifecycle: vim-motions:picker-ready workspace event fires after API installation; consumers use onLayoutReady + event listener pattern for load-order safety.
    • Type definitions: src/picker/picker-api.d.ts ships standalone types for consumer plugins with full JSDoc and usage examples.
    • Plugin: src/picker/api.ts (new), src/picker/picker-api.d.ts (new), src/picker/registry.ts (extended), src/picker/types.ts (metadata fields), src/main.ts (API wiring)
  • Meta-picker — :Picker (no arguments) opens a source browser listing all registered picker sources grouped by “Built-in” / “Extensions”, with display names, icons, and keymap bindings shown. Selecting an entry opens that picker. :Picker <source> opens a named source directly.
    • Plugin: src/picker/sources/pickers.ts (new), src/workspace/commands.ts (:Picker ex command), src/main.ts (picker-pickers Obsidian command)
  • Picker source metadata — PickerSource interface extended with optional displayName, icon, description, and priority fields. All 12 built-in sources now include metadata for display in the meta-picker.
    • Plugin: src/picker/types.ts, src/picker/sources/*.ts (all 12 source files)
  • Bundled picker integrations — three built-in picker sources that auto-detect and integrate with popular plugins:
    • Omnisearch (omnisearch) — dynamic full-text vault search via globalThis.omnisearch.search(). 150ms debounce, min 2-char query. Jumps to first match offset on selection.
    • Obsidian Tasks (tasks) — shows all incomplete tasks sorted by due date, grouped by status type. Cached with event-based invalidation via obsidian-tasks-plugin:cache-update. Jumps to task line on selection.
    • Dataview (dataview) — lists all Dataview-indexed pages with tags and aliases in description. Filterable by the picker’s fuzzy matcher.
    • All three are gated by settings toggles in Settings → Vim Motions → Third-party integrations (default: enabled) and by runtime plugin detection via onLayoutReady.
    • Plugin: src/picker/sources/omnisearch.ts (new), src/picker/sources/tasks.ts (new), src/picker/sources/dataview.ts (new), src/main.ts (detection + registration), src/settings.ts (3 toggles)

Fixed

  • Obsidian line numbers leaking into table cell editors — when Obsidian’s “Show line numbers” setting is enabled, line number gutters appeared inside embedded table cell editors where they shouldn’t. Fixed by suppressing .cm-gutters in cell editors via CSS. (#19)
    • Plugin: styles.css

Changed

  • enableMarkGutter migrated to signcolumn — the boolean toggle is now a dropdown with Auto/Always/Off modes matching Neovim’s signcolumn option. Existing settings are auto-migrated. set markgutter / set nomarkgutter remain as backward-compatible aliases.
  • Mark gutter internals refactored — the mark gutter implementation has been extracted into a dedicated sign-column.ts module. The rendering approach is unchanged (line decorations + CSS ::after overlay, zero layout shift), but the internal architecture now cleanly separates the sign column field from the refresh scheduling API. No user-facing changes.
    • Plugin: src/vim/sign-column.ts (new), src/vim/mark-gutter.ts (refactored to delegate)

Documentation

  • docs/configuration/settings.md: added numberwidth, cursorline, cursorlineopt, signcolumn, and foldcolumn settings
  • docs/configuration/vimrc.md: added new gutter options and markgutter alias to options tables
  • docs/configuration/lua-config.md: added new gutter options to vim.opt table
  • docs/features/marks.md: updated to mention signcolumn as the canonical way to toggle mark indicators
  • KNOWN_LIMITATIONS.md: added notes on signcolumn overlay behavior and cursorlineopt=screenline support
  • docs/configuration/settings.md: added Line numbers settings group
  • docs/configuration/vimrc.md: added number/nu, relativenumber/rnu to boolean options table
  • docs/configuration/lua-config.md: added vim.opt.number, vim.opt.relativenumber with hybrid mode tip
  • KNOWN_LIMITATIONS.md: removed number and relativenumber from “not implemented” options list
  • docs/development/picker-api.md: new provider API reference with consumer guide, API surface, integration examples (Omnisearch, Tasks), and lifecycle documentation
  • docs/development/index.md: added picker provider API link
  • docs/reference/keybindings.md: added :Picker / :Pick to ex commands table
  • KNOWN_LIMITATIONS.md: added picker provider API pop-out window limitation
  • docs/configuration/settings.md: added Third-party integrations settings group
  • docs/development/picker-api.md: added bundled integrations section
  • eslint.config.mts: added .obsidian-cache to global ignores (downloaded community plugin JS files)
  • wdio.conf.mts: added Omnisearch, Tasks, Dataview as enabled: false plugins for integration testing
  • test/specs/picker-integration.e2e.ts: 12 e2e tests covering source registration, picker opening, search results, meta-picker listing, and plugin disable/enable lifecycle

[0.51.1] - 2026-07-11

Fixed

  • Yank highlight over-extending on headings — linewise yank (yy) on a heading with text on the very next line highlighted both lines, even though only the heading line was yanked. The highlight range calculation used state.doc.lineAt(sel.to).to to find the end of the highlight, but the codemirror-vim fork’s expandSelectionToLine() sets sel.to to the start of the next line (via curEnd.line++). Calling lineAt() on that position resolved to the entire next line, extending the highlight one line too far. Fixed by using sel.to directly as the highlight end boundary, which already points to the correct position (the start of the line after the last yanked line). (#53)
    • Plugin: src/main.ts (attachYankHighlight linewise range calculation)

[0.51.0] - 2026-07-11

Added

  • Input method switching for CJK users — automatic IM switching when entering/leaving insert mode. Supports macism (macOS), im-select (macOS/Windows), fcitx5-remote (Linux), ibus (Linux), and any external IM switching binary. Per-editor IM state tracking, 50ms debounced switching, composition guard (never switches mid-IME composition), and error throttling with auto-disable. Desktop only — graceful no-op on mobile. (#55)
    • Lua API: vim.obsidian.im.get(), .set(id), .save(), .restore(), .enabled, .auto — programmatic control for advanced use cases. Set vim.obsidian.im.auto = false to disable auto-wiring and handle switching via Lua autocmds.
    • Settings: 7 new settings in Settings → Vim Motions → Input method — master toggle, binary path, obtain/switch args, normal mode IM, restore behavior (restore previous / use fixed default), default insert IM.
    • Security: Uses child_process.execFile (no shell interpretation). Binary path must be absolute. IM identifiers validated against shell metacharacters. Scoped process access following the external-fs.ts pattern.
    • Plugin: src/im/im-process.ts (new), src/im/im-switcher.ts (new), src/settings.ts, src/lua/obsidian-api.ts, src/lua/api.ts, src/lua/loader.ts, src/lua/autocmd.ts, src/main.ts, src/util/external-fs.ts
    • Tests: test/unit/im-process.test.ts (new), test/unit/im-switcher.test.ts (new)
  • CmdlineEnter/CmdlineLeave autocmd events — fire when entering/leaving the :, /, or ? command-line prompt. Event data includes cmdtype (":", "/", or "?"). CmdlineLeave is auto-wired to IM switching (switches to normal mode IM on prompt exit), matching im-select.nvim’s default behavior.
    • Plugin: src/lua/autocmd.ts, src/lua/loader.ts, src/vim/mode-tracker.ts, src/im/im-switcher.ts, src/main.ts

Documentation

  • docs/configuration/lua-config.md: added vim.obsidian.im API section with function reference and Lua examples
  • docs/configuration/settings.md: added “Input method” settings group table
  • KNOWN_LIMITATIONS.md: added IM switching limitations section (desktop-only, no command-line/search mode, Flatpak/Snap, system-wide switching); updated “Intentionally not supported” table to mark IM switching as built-in

[0.50.1] - 2026-07-11

Fixed

  • Table widget duplication with third-party decoration plugins — the embedded/cursor-aware table widget could show duplicated table content when another plugin (e.g., aDHL — Another Dynamic Highlights) applied Decoration.mark ranges over the same document region covered by the table’s Decoration.replace block widget. CM6’s decoration merging resolved the conflicting mark and replace decorations at default precedence, allowing raw table text to leak through alongside the rendered widget during re-entrant view updates. Fixed by wrapping the tableRenderField StateField in Prec.high(), ensuring the replace decoration takes precedence over default-priority mark decorations from other plugins. (#55)
    • Plugin: src/vim/table-render-widget.ts (Prec.high() wrap on tableRenderField)

[0.50.0] - 2026-07-11

Added

  • Fold viewport scroll compensation — the viewport automatically scrolls to keep the cursor visible after any fold/unfold operation, including Obsidian’s “Toggle fold properties” command. Uses a TransactionExtender for CM6 fold effects and a MutationObserver for the properties widget’s CSS class toggle. (#54)
    • Plugin: src/vim/fold-sync.ts (new), src/main.ts
  • Fold create/delete commands — zf{motion} creates a manual fold over the motion range (works in both visual and operator-pending mode). zd/zD delete the fold at the cursor. zE eliminates all folds in the document.
    • Ex commands: :folddelete (zd), :foldeliminate (zE)
    • Plugin: src/fold/commands.ts (new), src/operators/register.ts
  • Incremental fold level — zm folds one more heading level (h1 first, then h2, etc.). zr unfolds one heading level. A custom StateField tracks the current fold depth (0–6).
    • Ex commands: :foldmore (zm), :foldless (zr)
    • Plugin: src/fold/fold-level.ts (new), src/workspace/navigation.ts, src/main.ts
  • Markdown fold provider — custom foldService registers frontmatter (--- blocks) and callouts (> [!type]) as foldable regions. These are now foldable via zc/zo/za in addition to the standard CM6 heading and code block folds.
    • Plugin: src/fold/provider.ts (new), src/main.ts
  • Fold placeholder text — folded regions show descriptive placeholder text: heading title + line count, code block language, callout type, or frontmatter field count. Uses codeFolding({ preparePlaceholder, placeholderDOM }).
    • Plugin: src/fold/placeholder.ts (new), src/main.ts
  • Fold-aware navigation — when enabled, navigating into a folded section (e.g., ]h to a folded heading) automatically unfolds it. Matches Neovim’s default foldopen behavior. Configurable via Settings → Vim Motions → Fold-aware navigation (default: on).
    • Plugin: src/vim/fold-sync.ts, src/settings.ts
  • Fold persistence — fold state is remembered across file switches and sessions. Folds are captured on leaf change and restored when re-opening a file. Capped at 500 files with 30-day TTL eviction. Cleans up on file rename/delete.
    • Plugin: src/fold/persistence.ts (new), src/main.ts, src/settings.ts

Documentation

  • docs/features/workspace-navigation.md: added fold provider, placeholder, fold-aware navigation, and persistence documentation
  • docs/reference/keybindings.md: added zf, zd, zD, zE, zm, zr keybindings
  • docs/features/ex-commands.md: added :folddelete, :foldeliminate, :foldmore, :foldless
  • docs/configuration/settings.md: added Fold-aware navigation and Fold persistence settings
  • KNOWN_LIMITATIONS.md: added zn/zN as known deviations; updated fold command coverage
  • test/neovim-command-index.yaml: added 6 new fold command entries

[0.49.0] - 2026-07-11

Added

  • Which-key sort order setting — configurable sort order for the which-key popup. “which-key” (default) matches which-key.nvim defaults: individual keys first, groups last, alphanumeric before special keys, natural alphabetical tiebreaker. “Groups first” shows groups before individual keys, both sorted alphabetically. Configurable via Settings → Vim Motions → Which-key sort order, vim.opt.whichkeysort in Lua, or set whichkeysort=<order> (alias wks) in vimrc.
    • Plugin: src/ui/which-key.ts (sortWhichKeyEntries, WhichKeySortOrder type), src/ui/global-which-key.ts, src/settings.ts, src/main.ts, src/vim/options.ts, src/vimrc/loader.ts
  • Which-key Lucide icons — optional Lucide icon support for the which-key popup, inspired by which-key.nvim. Icons render as inline SVGs via Obsidian’s setIcon() API, colored using Obsidian’s CSS color variables or arbitrary CSS color strings. Each row displays: key → separator (➤) → icon → description, matching which-key.nvim’s column layout.
    • Global toggle: whichKeyIcons setting (default: on). Configurable via Settings UI, vim.opt.whichkeyicons in Lua, or set whichkeyicons / set nowhichkeyicons in vimrc.
    • Per-entry icons: assign icon and color to any group label or command label via Settings UI, Lua (vim.obsidian.whichkey.set_group("<leader>t", "Table", { icon = "table", color = "blue" })), or vimrc (whichkeygroup <leader>t Table icon=table color=blue).
    • Color system: 8 named Obsidian colors (red, orange, yellow, green, cyan, blue, purple, pink) mapped to theme CSS variables, plus arbitrary CSS color strings. Default icon color: --text-muted.
    • Default icons: Table (table, blue), EasyMotion (zap, yellow), Harpoon (anchor, orange) — applied automatically to built-in groups.
    • Alignment: spacer spans for rows without icons when icons are globally enabled, ensuring consistent column alignment.
    • Plugin: src/ui/which-key.ts (WhichKeyLabelInfo, resolveIconColor), src/ui/global-which-key.ts, src/settings.ts (GroupLabel/CommandLabel extended with icon?/color?), src/main.ts, src/lua/api.ts, src/lua/obsidian-api.ts, src/lua/loader.ts, src/vim/options.ts, src/vimrc/loader.ts, src/vimrc/parser.ts, src/workspace/global-mapping-registry.ts, src/easymotion/register.ts, src/motions/tables.ts, styles.css
  • Harpoon-style file pinning — pin files to numbered slots for instant switching. <leader>ha pins, <leader>1–<leader>9 jumps to slots, <leader>hp opens the harpoon picker. Cursor position is tracked per-pinned-file and restored on navigation. Pins persist across sessions. File renames auto-update pins; file deletes auto-remove them.
    • 6 ex commands: :HarpoonAdd, :HarpoonRemove [N], :Harpoon, :HarpoonSelect N, :HarpoonNext, :HarpoonPrev
    • 14 Obsidian commands for command palette access
    • 15 leader keybindings with which-key “Harpoon” group
    • Picker with slot-ordered display, fuzzy search, preview, and split-open support
    • Plugin: src/vim/harpoon-store.ts (new), src/vim/harpoon-nav.ts (new), src/picker/sources/harpoon.ts (new), src/main.ts, src/settings.ts

Documentation

  • docs/configuration/which-key.md: added Sort order and Icons sections with Lua/vimrc/Settings examples, color table, and default icons
  • docs/configuration/settings.md: added Which-key sort order and Which-key icons rows to Which-key hints table
  • docs/configuration/vimrc.md: added whichkeysort (wks) and whichkeyicons (wki) options
  • docs/configuration/lua-config.md: added whichkeysort and whichkeyicons options to vim.opt table
  • KNOWN_LIMITATIONS.md: added Sort order and Icons subsections to Which-key overlay section
  • docs/features/harpoon.md: new feature page
  • docs/features/index.md: added harpoon to Jump navigation section
  • docs/configuration/settings.md: added Harpoon file pinning to Jump navigation table
  • docs/reference/keybindings.md: added Harpoon section with leader bindings and ex commands

[0.48.0] - 2026-07-11

Added

  • Mark gutter indicators — vim mark letters (a–z, A–Z) appear in the gutter area next to marked lines, providing visual feedback on where marks are set. Multiple marks on the same line are shown together (e.g., ab). The indicators use line decorations with CSS ::after positioning — zero horizontal space consumed, no document shift. Updates on mark set/move/delete and on document edits. Toggle via Settings → Vim Motions → Vim features → Mark gutter indicators (default: on).
    • Plugin: src/vim/mark-gutter.ts (new), src/main.ts, src/settings.ts, styles.css
  • Global mark persistence — marks A–Z are persisted across files and plugin restarts. Setting mA stores the file path and cursor position; navigating to 'A from any file opens the target and jumps to the saved position. Marks are saved via a 30-second polling interval with dirty-flag checking, plus immediate save on unload.
    • Plugin: src/vim/mark-store.ts (new), src/main.ts, src/settings.ts (persistedMarks field)
  • Enhanced marks picker — :marks and <leader>fm now show marks grouped by category: “Buffer marks” (a–z) with line preview, “Global marks” (A–Z) with file path. Selecting a global mark opens the target file and navigates to the saved position. Built on a MarkProvider abstraction for future extensibility (harpoon-style file marks).
    • Plugin: src/picker/sources/mark-providers.ts (new: MarkProvider interface, VimBufferMarkProvider, GlobalMarkProvider), src/picker/sources/marks.ts (rewritten), src/picker/types.ts (group field), src/picker/picker.ts (group header rendering)
  • Picker group headers — PickerItem interface extended with optional group field. When set, the picker renders non-selectable section headers when the group changes between consecutive items. Available to all picker sources.
    • Plugin: src/picker/types.ts, src/picker/picker.ts, styles.css

Fixed

  • :delmarks not refreshing gutter — deleting marks via :delmarks a did not update the mark gutter indicators because the plugin’s ex command handler didn’t trigger a gutter refresh. Fixed by adding an onMarksChanged callback to createDelmarksCommand that schedules a gutter refresh.
    • Plugin: src/workspace/commands.ts, src/main.ts
  • Yank highlight not working on first load — the yank highlight handler was not attached to the initially open editor on plugin startup, only activating after switching to a different pane. reloadFeatures() now calls attachYankHighlight() (cleanup + re-attach) instead of only cleaning up, ensuring the handler is attached when vimrc/lua loading triggers the first feature reload. (#53)
    • Plugin: src/main.ts (reloadFeatures calls attachYankHighlight())

Documentation

  • docs/features/marks.md: new feature page covering mark gutter indicators, global mark persistence, and grouped marks picker
  • docs/features/index.md: added marks entry to Quality of life section
  • docs/configuration/settings.md: added Mark gutter indicators row to Vim features table
  • docs/reference/keybindings.md: updated :marks description to reflect grouped picker
  • docs/features/quality-of-life.md: added “Yank highlight” section with mode descriptions, configuration, CSS override tip, and fork-mode-only callout
  • docs/features/index.md: added yank highlight to Quality of life bullet
  • docs/configuration/settings.md: added Yank highlight and Yank highlight duration rows to Vim features table; added CSS override tip callout
  • KNOWN_LIMITATIONS.md: updated “Yank highlighting” row from external plugin recommendation to built-in; added marks section

[0.47.0] - 2026-07-10

Added

  • Yank highlight — yanked text is briefly highlighted, providing visual feedback on what was yanked. Three modes available in Settings → Vim Motions → Vim features → Yank highlight: “Solid” (default, Neovim-style — instant appear, hold, disappear), “Fade” (gradual fade-out animation), or “Off”. Duration is configurable via the Yank highlight duration slider (50–3000ms, default 200ms). Highlight color adapts to the active theme via --text-accent and can be overridden with the --vim-motions-yank-bg CSS custom property. Respects prefers-reduced-motion. Replaces the external obsidian-vim-yank-highlight plugin. (#53)
    • Works with remapped yank keys (detects actual yank operations via the vim-yank event, not keypress sniffing)
    • Handles rapid successive yanks (new highlight replaces previous), large yanks (>1000 lines skipped), and disposed views (tab close during highlight)
    • Requires bundled fork mode (built-in vim mode OFF) — the built-in vim does not emit the vim-yank event
    • Blockwise yank highlight deferred to a future release
    • Plugin: src/vim/yank-highlight.ts (new), src/main.ts, src/settings.ts, styles.css
  • Embedded table editing mode — new 'embedded' option for Settings → Vim Motions → Table widget in Live Preview. Tables render as themed HTML with a two-layer editing model:
    • Table navigation: h/j/k/l moves a cell highlight across the rendered table. j/k at the top/bottom row exits the table.
    • Cell editing: i/a/c/s/Enter opens a vim-enabled editor in the highlighted cell with full vim support (modes, motions, text objects, auto-formatting). Escape returns to table navigation; a second Escape exits the table. Tab/Shift-Tab moves between cells.
    • Table manipulation: o/O (add row below/above), dd (delete row), dc (delete column), J/K (move row down/up), H/L (move column left/right), I/A (add column left/right), = (realign). These operate directly on the raw markdown — no dependency on Obsidian’s internal table commands.
    • Cell edits are written back per-cell with per-cell undo granularity.
    • Configurable via set tablewidget=embedded in vimrc or vim.opt.tablewidget = "embedded" in Lua.
    • Plugin: src/vim/table-nav-controller.ts (new), src/vim/table-cell-editor.ts (new), src/vim/table-operations.ts (new), src/vim/table-utils.ts (new), src/vim/table-render-widget.ts (modified: data attributes, embedded mode toggle, re-render guard), src/vim/table-embedded-editor.ts (rewritten), src/vim/table-auto-format.ts (getVimMode accepts EditorView), src/motions/tables.ts (exported helpers), src/main.ts, src/settings.ts, src/vim/options.ts, src/vimrc/loader.ts, styles.css

Changed

  • Oil explorer architecture rewritten — oil no longer creates temporary oil~*.md files in the vault. The directory listing is rendered in a dedicated oil-explorer view type with an embedded CodeMirror 6 editor, eliminating temp file visibility in tabs, search, and graph. Vim mode (both built-in and bundled fork) works natively in the embedded editor. View state (current directory) persists across workspace restarts via getState()/setState().
    • New: reusable EmbeddableMarkdownEditor abstraction (src/editors/embeddable-editor.ts) — extracts Obsidian’s internal ScrollableMarkdownEditor prototype via app.embedRegistry and exposes a lightweight editor mountable in any DOM container with full CM6 + vim support. Designed for future use by the table editor and other features needing embedded markdown editing.
    • New: OilView custom view (src/oil/oil-view.ts) — extends View with view type 'oil-explorer', embedded editor with oil conceal extension, directory state management, and previous-file tracking for workspace restoration on close.
    • Changed: :q/:wq/:x/q in oil now restore the previously open file instead of leaving an empty workspace.
    • Changed: Oil keybinding registration uses vim.map with <CR> notation instead of vim.noremap with literal newline, fixing command execution in the embedded editor.
    • Changed: Lua vim.obsidian.oil.* callbacks call manager methods directly instead of routing through ex commands, fixing Lua API calls when no MarkdownView is active.
    • Removed: OIL_TEMP_PREFIX, tempToDir map, getTempFilePath(), forgetTempPath(), cleanupOrphanedTempFiles(), forceSourceMode(), userIgnoreFilters management, CSS .nav-file-title[data-path^='oil~'] hiding rule.
    • Migration: Legacy oil~*.md files from previous versions are automatically cleaned up on first load (cleanupLegacyTempFiles()).
    • Plugin: src/editors/embeddable-editor.ts (new), src/oil/oil-view.ts (new), src/oil/manager.ts (rewritten), src/oil/keybindings.ts (refactored), src/oil/render.ts (OIL_TEMP_PREFIX filter removed), src/workspace/commands.ts (OilView detection for :w/:wq/:x/:q), src/main.ts (registerView, Lua callbacks simplified), styles.css (OilView styling), test/specs/oil-poc.e2e.ts (rewritten for OilView, 16 tests)

Fixed

  • Oil confirm dialog button not focused — the confirmation dialog shown when deleting files now auto-focuses the Confirm button, matching pre-migration behavior.

[0.46.0] - 2026-07-10

Fixed

  • Which-key overlay hidden behind status bar — the which-key popup (both editor-level and global workspace) was positioned at bottom: 0 of its container, causing the bottom rows to be obscured by Obsidian’s status bar. The overlay now detects the status bar height and adds padding-bottom to keep content above it. In split views, padding is only applied when the editor pane’s bottom edge is adjacent to the status bar (top splits are unaffected).
    • Plugin: src/ui/which-key.ts (status bar height detection with getBoundingClientRect adjacency check), src/ui/global-which-key.ts (same padding for global which-key)

Added

  • Remappable keybindings — every plugin keybinding is now user-remappable across all contexts (editor, oil explorer, picker, global workspace navigation). See the remapping guide for details.
    • 46 new ex command aliases for editor-context actions: structural navigation (:nextheading, :prevheading, :nextheading1–6, :prevheading1–6, :nextlistitem, :prevlistitem, :nextlink, :prevlink, :nextbuffer, :prevbuffer), table navigation (:tablenextcell, :tableprevcell, :tablenextrow, :tableprevrow), workspace navigation (:focuspaneleft/right/up/down, :splitvertical, :splithorizontal, :closetab, :closeothertabs, :nexttab, :prevtab, :gototab, :gotodefinition, :foldclose/open/toggle/all, :unfoldall, :documentoutline, :openurl, :docstats, :renamenote, :showbacklinks, :opengotofile, :contextactions, :charinfo), and hint mode (:hintactivate, :hintopennew, :hintyank, :hintclose). Users can remap any keybinding via nmap key :excommand<CR> in vimrc or vim.keymap.set('n', 'key', ':excommand<CR>') in Lua.
    • Plugin: src/keybindings/action-registry.ts (new: exCommandFromMotion/exCommandFromAction helpers), src/motions/register.ts, src/workspace/navigation.ts, src/main.ts
  • Oil explorer remappable keybindings — all 9 oil keybindings are now user-remappable via Lua autocmds or vimrc
    • 9 oil ex commands: :oilopen, :oilparent, :oilroot, :oilrefresh, :oilclose, :oiltogglehidden, :oilcyclesort, :oilyankpath, :oilreveal
    • 8 new Lua functions in vim.obsidian.oil: parent(), root(), refresh(), toggle_hidden(), cycle_sort(), yank_path(), reveal(), open_entry()
    • OilEnter/OilLeave autocmd events — fire when entering/leaving an oil buffer, enabling Neovim-style buffer-local keymaps
    • Oil defaults now registered as vim.noremap mappings pointing to ex commands (previously mapCommand), making them visible in :map output and overridable by user mappings
    • Plugin: src/oil/keybindings.ts (refactored), src/lua/api.ts, src/lua/obsidian-api.ts, src/lua/loader.ts, src/main.ts
  • Picker keybinding configurability — picker modal keybindings (<C-n>, <C-p>, <C-x>, <C-v>, <C-t>, <C-d>, <C-u>) are now configurable via Lua
    • vim.obsidian.pick_keymap() accepts a table of action→key arrays with snake_case field names
    • Custom keymap persisted in settings and applied to all picker instances including tag sub-pickers
    • Plugin: src/picker/types.ts (PickerKeymap, matchesPickerKey), src/picker/picker.ts (refactored keydown handler), src/picker/sources/tags.ts, src/settings.ts, src/lua/api.ts, src/lua/obsidian-api.ts, src/lua/loader.ts, src/main.ts
  • Global workspace navigation remappable keybindings — non-editor keybindings (<C-w>*, gt/gT, j/k scroll, H/L, :) are now remappable
    • vim.obsidian.keymap.set/del now operate on the live GlobalMappingRegistry at runtime (previously only at config-load time)
    • :gmap key :command — new ex command to add global keybindings from the editor command line or non-editor : modal
    • :gunmap key — new ex command to remove global keybindings
    • :gmaps — renamed from :gmap (display-only) to avoid collision with the new mapping command
    • All 26 default global mappings tagged with stable name fields for documentation
    • Plugin: src/workspace/global-mapping-registry.ts, src/workspace/global-defaults.ts, src/ui/global-ex-command.ts, src/workspace/commands.ts, src/lua/loader.ts, src/main.ts
  • Remapping guide — new documentation page docs/configuration/remapping.md with examples for all 4 remapping contexts (editor, oil, picker, global)

Changed

  • :gmap (display) renamed to :gmaps — the :gmap command now creates global keybindings instead of displaying them. Use :gmaps to list all active global mappings.
  • Autocmd events — 15 → 17 supported events (added OilEnter, OilLeave)

Documentation

  • docs/configuration/remapping.md: new unified remapping guide
  • docs/features/oil-explorer.md: added remapping section with ex commands table, Lua examples, vimrc examples, and vim.obsidian.oil function reference
  • docs/features/ex-commands.md: added navigation/action/oil/hint/global mapping ex command tables; updated command count to 100+
  • docs/reference/keybindings.md: added ex command column to oil table; added remapping section link
  • docs/configuration/lua-config.md: added vim.obsidian.oil namespace (10 functions), OilEnter/OilLeave autocmd events, vim.obsidian.pick_keymap() API
  • docs/configuration/vimrc.md: updated :gmap/:gunmap/:gmaps documentation
  • docs/configuration/index.md: added remapping guide to quick links
  • KNOWN_LIMITATIONS.md: updated keybinding remappability section to “Implemented” across all contexts; updated autocmd event count to 16
  • AGENTS.md: updated change-to-page routing table with configuration/remapping.md

[0.45.0] - 2026-07-09

Fixed

  • gk/gj takes extra keypress to traverse non-wrapped headings — gk required two presses to cross a heading line that was visually tall (large font/line-height) but did not wrap. CM6’s moveVertically saw the heading’s line block as spanning multiple defaultLineHeight steps, causing a spurious within-line cursor move before crossing to the adjacent line. The fork’s findPosV now detects when moveVertically stays on the same document line with negligible Y-coordinate change (less than half defaultLineHeight via coordsAtPos comparison) and force-moves to the adjacent document line. Legitimate wrapped-line navigation (Y delta ≥ threshold) is unaffected. (#26)
    • Fork: src/cm_adapter.ts (findPosV Y-delta spurious move detection)

Added

  • Oil explorer — oil.nvim-inspired file explorer that renders vault directories as editable buffers. Create, rename, delete, and move files with standard vim commands, then commit all changes with :w. (oil.nvim-inspired)
    • :Oil [path] opens the current file’s directory (or a specified path) as an editable buffer in a new tab. Each line represents a file or folder with a concealed entry ID. The buffer is a regular markdown file — all existing vim features (EasyMotion, surround, text objects, which-key, status bar) work natively.
    • File operations via vim commands: o (new line) + :w creates a file, dd + :w deletes, cw + :w renames. Filenames without an extension default to .md. Names ending with / create folders. Renames update backlinks via app.fileManager.renameFile(). Deletes respect user trash settings via app.fileManager.trashFile().
    • Cross-directory moves: dd in one oil buffer, p in another, :w moves the file. The diff engine detects moves by matching entry IDs across buffers.
    • Navigation keybindings (active only in oil buffers): <CR> open/enter, - parent directory, ~ vault root, q close, <C-l> refresh, g. toggle hidden files, gs cycle sort order, y. yank file path
    • Auto-refresh: vault event listeners (create/delete/rename) with 200ms debounce refresh open oil buffers when files change externally
    • Confirmation dialog: shown when deleting files exceeding the configurable threshold (default: 1)
    • Stale file cleanup: orphaned temp files from previous sessions are removed on plugin startup
    • Tab title: reflects current directory (e.g., oil~notes) and updates on navigation
    • :w/:wq/:x/:update dispatch: active file path is checked for the oil~ prefix — oil commits route through the diff/validate/execute pipeline; normal files save normally
    • Global ex command: :Oil available in the non-editor : command modal (same pattern as picker commands)
    • Setting gate: :Oil command and keybindings only registered when the oilExplorer setting is enabled (default: on)
    • Plugin: src/oil/ (manager.ts, cache.ts, diff.ts, actions.ts, render.ts, parser.ts, extensions.ts, keybindings.ts, types.ts), src/workspace/commands.ts (:w dispatch, :Oil registration), src/main.ts (OilManager/OilKeybindingManager lifecycle), src/settings.ts (4 settings), src/ui/global-ex-command.ts (:Oil in global modal), src/workspace/global-defaults.ts (oil manager threading), styles.css (file explorer hiding)
  • Oil explorer Lua API — vim.obsidian.oil.open(path) opens oil for a directory, vim.obsidian.oil.close() closes the active oil buffer and cleans up the temp file
    • Plugin: src/lua/api.ts (oilOpen/oilClose callbacks), src/lua/obsidian-api.ts (vim.obsidian.oil sub-table), src/lua/loader.ts (callback wiring)
  • Oil explorer settings — 4 new settings in Settings → Vim Motions → File explorer:
    • oilExplorer (toggle, default: on) — enable/disable the oil explorer
    • oilShowHiddenFiles (toggle, default: off) — show dotfiles in oil views
    • oilConfirmDeleteThreshold (slider, 1–20, default: 1) — confirmation dialog threshold
    • oilDefaultSort (dropdown: name/mtime/size, default: name) — directory sort order
    • Plugin: src/settings.ts
  • Oil explorer e2e test suite — 10 regression tests covering: :Oil opens temp file, regular markdown view, vault file listing with concealment, current-directory default, file creation, folder creation, file deletion, file rename, no-op save, and temp file exclusion from listings
    • Plugin: test/specs/oil-poc.e2e.ts
  • wdio.conf.mts workspace cleanup — onPrepare hook deletes stale workspace.json before e2e tests to prevent flaky failures from leftover workspace state
  • Spike test for reporter’s exact content (spike-gk-issue26-repro.e2e.ts, 6 tests: full-document gk/gj traversal, consecutive h2 headings, long wrapped line, h2-longline-h2 transitions)

Changed

  • Oil temp file hiding — oil temp files (oil~*.md) are hidden from the file explorer via a static CSS prefix selector ([data-path^="oil~"]) in styles.css, and from search/graph/quick switcher via Obsidian’s userIgnoreFilters mechanism. User-configured ignore filters are preserved — oil only adds/removes its own entries.

Documentation

  • docs/features/oil-explorer.md: new feature page covering overview, opening commands, file operations, navigation, configuration, and implementation details
  • docs/features/index.md: added oil explorer entry to workspace & commands section
  • docs/reference/keybindings.md: added Oil explorer keybinding table (13 entries)
  • docs/configuration/settings.md: added File explorer settings group (4 settings)
  • KNOWN_LIMITATIONS.md: added oil explorer section with cross-directory move requirements, temp file mechanism, and dotfile limitation
  • KNOWN_LIMITATIONS.md: updated “Visual line navigation” section with three-correction architecture (multi-line clamp, tall non-wrapped line detection, column 0 fallback); updated test coverage note
  • Fork DIFFERENCES.md: updated “Widget-aware vertical navigation” section with Y-delta spurious move detection

[0.44.1] - 2026-07-09

Removed

  • nucleo-matcher-wasm dependency removed — the WASM-based fuzzy matcher from the Helix editor has been removed. The nucleo and auto picker engine options are no longer available. The bundled wasm-bindgen glue code contained a fetch() call (in the unused async init path) that triggered the Obsidian community directory scanner’s network request warning, along with other WASM-related scanner flags. Since uFuzzy performs comparably and nucleo was disabled by default, the dependency has been dropped entirely to eliminate scanner warnings and reduce bundle size.
    • Plugin: src/picker/matcher-nucleo.ts (deleted), src/picker/matcher.ts (nucleo branch and auto/nucleo engine options removed), src/settings.ts (pickerMatcherEngine type narrowed to 'ufuzzy' | 'obsidian', dropdown options reduced), esbuild.config.mjs (WASM binary loader plugin removed), package.json (nucleo-matcher-wasm dependency removed)
    • Tests: test/unit/picker/matcher.test.ts (nucleo engine removed from test matrix, nucleo-specific test suite removed), test/bench/matcher.bench.ts (nucleo benchmarks removed), test/specs/picker.e2e.ts (nucleo removed from engine switching test)
    • Docs: KNOWN_LIMITATIONS.md (nucleo entries removed from engine list, limitations, and bundle size section), docs/configuration/settings.md (engine options updated), docs/index.md (0.44.0 summary updated), ACKNOWLEDGEMENTS.md (nucleo attribution removed), AGENTS.md (nucleo-matcher-wasm fork section removed)

Changed

  • Picker matching engine — setting reduced from four options (ufuzzy, nucleo, obsidian, auto) to two (ufuzzy, obsidian). Default remains ufuzzy.
  • Bundle size — production bundle reduced by ~193KB (embedded WASM binary) plus ~30KB of wasm-bindgen glue code.

[0.44.0] - 2026-07-09

Changed

  • Picker modal Telescope-style presentation — the unified fuzzy picker now uses a terminal-inspired visual style matching the which-key overlay aesthetic. All text elements use var(--font-monospace) at compact sizes (11–13px). Items are denser (3px vertical padding, no minimum height). The selected item uses an accent-tinted background (hsla(var(--interactive-accent-hsl), 0.15)) instead of the generic hover color. The modal itself has minimal border-radius (2px), a subtle box-shadow, and an accent-colored border on the input and results panels. The result count bar uses var(--text-faint) at 11px with a border separator. Preview pane font sizes are unified at 12px. All colors use Obsidian CSS variables for full theme compatibility. (telescope.nvim-inspired)
    • Plugin: styles.css (picker CSS section rewritten)
  • Picker floating border titles — each picker section (prompt, results, preview) now displays a centered title label that overlays the top border, matching telescope.nvim’s ─── Files ─── presentation. The prompt shows the source name (e.g. “Files”, “Buffers”, “Commands”, “Livegrep”), the results list shows “Results”, and the preview pane shows “Preview”. Titles use monospace font at 11px with var(--text-muted) color and a var(--modal-background) background to mask the border behind them.
    • Plugin: src/picker/picker.ts (formatTitle helper, .vim-motions-picker-section wrapper divs with .vim-motions-picker-title spans for input, results, and preview sections), styles.css (.vim-motions-picker-section, .vim-motions-picker-title rules, updated flex layout for preview body wrappers)
  • Picker positional previews use raw text — positional previews (grep, live grep, headings, marks) now render as monospace plain text instead of rendered markdown. This ensures uniform line heights so the line-number gutter stays perfectly aligned with the content — MarkdownRenderer.render() produces variable-height elements (headings, block elements) that caused the gutter and content to drift apart. Non-positional previews (full file preview without line numbers) continue to use markdown rendering.
    • Plugin: src/picker/picker.ts (renderMarkdownPreview positional branch rewritten to emit <pre> with per-line <div> elements), styles.css (.vim-motions-picker-preview-code, .vim-motions-picker-preview-code-line rules)

Fixed

  • Neovim golden recorder produced incorrect results for visual-block operations — the NeovimClient.input() method used nvim_feedkeys with 'tx' flags, which does not fully execute block-insert replication (where <C-v>I/A + text + <Esc> applies the inserted text to all selected lines) or visual mode-switch + operator combos within a single RPC call. Block insert operations only appeared on the last selected line, and <C-v> → v/V mode switches produced incorrect deletion scopes. Fixed by using :execute "normal ..." (via nvim.command()) for key sequences containing <C-v>, which processes synchronously within Neovim’s command loop. Non-block sequences still use nvim_feedkeys (needed for macro recording/replay which :normal doesn’t support). Added escapeForNormal() helper to convert control characters to Vim \<...> notation.
    • Plugin: test/neovim/client.ts (hybrid input() method, escapeForNormal function)
    • Golden data: upstream-gaps.json (4 cases corrected), visual-block.json (15 cases corrected — block I/A/c/C/x/~ now correctly affect all selected lines), select-mode.json and select-mode-extended.json (minor corrections from improved key processing)
  • Picker preview gutter misaligned on files with frontmatter — positional previews (grep, live grep, headings, marks) showed line numbers for YAML frontmatter lines, but MarkdownRenderer.render() silently strips frontmatter from the output. This caused the rendered text to shift up relative to the gutter by the number of frontmatter lines. Fixed by detecting ----delimited frontmatter in readLinesAroundPosition and clamping the preview slice to start after the frontmatter block, so both the gutter and content exclude frontmatter lines.
    • Plugin: src/picker/sources/preview-utils.ts (getFrontmatterEnd helper, effectiveStart clamping in readLinesAroundPosition)

Added

  • Picker matching engine setting — selectable fuzzy matching engine for the picker (Settings → Vim Motions → Picker matching engine). Four options: ufuzzy (default), nucleo, obsidian, auto. The setting takes effect immediately on the next picker invocation without restarting Obsidian.
    • uFuzzy (default): Pure JavaScript matcher with filename-aware ranking — prefers exact filename prefix matches over partial path matches (e.g., Header.tsx ranks above header/utils.ts for query "Header"). Fastest engine in benchmarks across all query types. Supports typo tolerance.
    • nucleo (opt-in): WASM-compiled matcher from the Helix editor (~193KB binary). Provides fzf-compatible scoring with optimal Smith-Waterman alignment and path-aware matching. Fork at saberzero1/nucleo-matcher-wasm adds matchLiteralIndexedWithIndices and matchPatternIndexedWithIndices methods for efficient WASM boundary crossing.
    • obsidian (opt-in): Obsidian’s built-in prepareFuzzySearch API. Zero bundle cost. May be slower on large vaults.
    • auto: nucleo on desktop, uFuzzy on mobile. Falls back to uFuzzy if WASM initialization fails.
    • Plugin: src/picker/matcher.ts (factory), src/picker/matcher-ufuzzy.ts (enhanced sort), src/picker/matcher-nucleo.ts (WASM adapter), src/picker/matcher-obsidian.ts (Obsidian API adapter), src/picker/matcher-utils.ts (shared utilities), src/settings.ts (pickerMatcherEngine setting), esbuild.config.mjs (WASM binary loader plugin)
  • Enhanced uFuzzy file-picker sort — the uFuzzy matcher now uses a filename-aware ranking algorithm instead of the default sort. The sort prefers: (1) exact filename prefix matches, (2) shorter basenames among prefix matches, (3) filename matches over path-only matches, (4) more exact term boundaries, (5) tighter fuzzy matches, (6) shorter paths. The info phase is capped at 500 items with filename-prefix candidates prioritized, keeping sort overhead bounded for broad queries. Benchmarks show this produces the same #1 result as nucleo’s Smith-Waterman scoring for 7 out of 8 test queries at ~25% overhead vs the default sort.
    • Plugin: src/picker/matcher-ufuzzy.ts (filePickerSort function, 3-phase filter() → info() → custom sort pipeline)
  • Matcher benchmark suite — npm run test:bench runs a vitest benchmark comparing all three matching engines (uFuzzy, nucleo, obsidian) across 8 query patterns at 1K/5K/10K item counts. Uses realistic file path data (16 directories × 50 filenames × 6 extensions).
    • Plugin: test/bench/matcher.bench.ts, vitest.config.ts (benchmark configuration), package.json (test:bench script)
  • Picker engine switching e2e test — validates that all three engines (ufuzzy, nucleo, obsidian) can be switched at runtime via settings and produce results in the picker.
    • Plugin: test/specs/picker.e2e.ts (matcher engine switching section)
  • Matcher unit tests expanded — parameterized test suite runs 18 shared test cases across all three engines (54 tests total). Nucleo-specific tests cover fzf syntax chars as literals, emoji UTF-32/UTF-16 index correction, CJK characters, and 10K-item performance. Matcher-utils tests cover indicesToRanges and utf32ToUtf16Indices.
    • Plugin: test/unit/picker/matcher.test.ts (68 tests, up from 18)
  • 3 Neovim golden comparison cases for gk column preservation across headings (gk over heading preserves column, gk over heading then above preserves column, gk gj round-trip preserves column), recorded against Neovim 0.12.2
  • 2 spike test suites: spike-gk-font-variations.e2e.ts (11 tests: CSS theme stress-testing with varying font sizes, line heights, heading sizes, editor widths, padding/margins), spike-gk-column-drift.e2e.ts (4 tests: column drift measurement per heading level with Neovim comparison data)

Documentation

  • KNOWN_LIMITATIONS.md: updated picker section with four-engine description, filename-aware ranking, bundle size impact; added “gk/gj column drift on heading lines” section documenting the pixel-vs-character column deviation from Neovim with measurement data table; added gj/gk column row to behavioral deviations table with “Pixel drift” status; updated golden test coverage note (7 → 10 heading tests, 3 golden comparison cases)
  • docs/configuration/settings.md: added picker matching engine setting row with four options and notes section
  • AGENTS.md: added nucleo-matcher-wasm fork section (dependency URL, build instructions, WASM binary size, fork API additions, license)
  • ACKNOWLEDGEMENTS.md: added third-party attribution for nucleo-matcher-wasm (MPL-2.0), codemirror-vim (MIT), fengari (MIT)
  • test/neovim/deviations.ts: registered 2 known deviations for gk column preservation across heading lines (pixel-based posAtCoords vs Neovim’s character-based curswant)
  • Fork DIFFERENCES.md: updated “Widget-aware vertical navigation” section with clamp-all-jumps approach and posAtCoords column fixup relaxation

[0.43.0] - 2026-07-08

Fixed

  • Cursor snapping over double-character formatting marks in Live Preview — moving through **bold**, __underline__, ~~strikethrough~~, or ==highlight== with h/l skipped positions inside the **/__/~~/== delimiters instead of visiting each character. The cursor would jump from the first delimiter character to the content, skipping the second delimiter character. Investigation found that the EditorState.transactionFilter introduced to correct cursor positioning near formatting marks was the sole cause of the snapping — Obsidian’s Live Preview natively handles mark visibility based on cursor proximity, and all formatting marks are full-width DOM elements on the active line. The transaction filter, the formattingMarkMode setting, and the formattingmarkmode vim option have been removed. (#33)
    • Plugin: removed src/vim/formatting-mark-fix.ts, src/vim/formatting-mark-ranges.ts; removed formattingMarkMode from settings interface, defaults, settings UI, Style Settings definition, vimrc loader, and vim options
  • gk/gj still skips lines in documents with mixed headings and lists — gk could jump over multiple document lines when navigating upward through a document containing headings of varying sizes (###, ####) separated by empty lines. The previous fix (v0.18.0) only clamped multi-line jumps when a replaced widget decoration (dec.point) was present in the skipped range, so headings — which use mark decorations with larger fonts, not replaced widgets — still triggered overshooting from CM6’s pixel-based moveVertically. The fork’s findPosV now clamps all multi-document-line jumps to ±1 when no fold is present, regardless of decoration type. posAtCoords resolves horizontal position on the clamped target line; the goalColumn > 0 guard is relaxed to goalColumn != null so the column fixup also fires at column 0. (#26)
    • Fork: src/cm_adapter.ts (findPosV line-jump clamp, posAtCoords resolution on clamped target)

Added

  • 6 regression tests for cursor movement through double-character formatting marks (**, __, ~~, ==) — asserts every position is visited in both l and h directions
  • E2E tests for gk over h4/h5/h6 headings: cursor horizontal position preserved across all heading levels
  • E2E test for gk through mixed headings, text, and lists: verifies no document lines are skipped and horizontal position is preserved on non-empty lines

Documentation

  • KNOWN_LIMITATIONS.md: updated “Visual line navigation and replaced widget decorations” section with clamp-all-jumps approach; updated gj/gk widgets behavioral deviation entry; updated test coverage count (3 → 7 heading tests)

[0.42.0] - 2026-07-08

Added

  • Mobile opt-in setting and toggle command — the plugin is now disabled by default on mobile devices. A new enableOnMobile setting (default: off) controls whether the plugin activates on mobile. When disabled, the plugin skips all Vim engine initialization — no editor extensions, event listeners, commands, or status bar elements are registered — leaving Obsidian’s editor in its default state. The settings tab and a toggle command (Vim Motions: Toggle enable on mobile) remain accessible even when the plugin is disabled, so users can re-enable without needing a desktop device. Changing the setting requires an Obsidian reload. Hardware keyboard users on tablets can opt in; soft-keyboard-only users are no longer stuck in Normal mode with no way to escape. (#52)
    • Plugin: src/settings.ts (enableOnMobile in VimMotionsSettings interface, DEFAULT_SETTINGS, getSettingDefinitions() Mobile group, display() Mobile toggle), src/main.ts (early return in onload() when Platform.isMobile && !enableOnMobile, toggle-enable-on-mobile command registered before the gate)
  • showConfigNotifications setting — a new toggle in Settings → Vim Motions → Vimrc & key bindings → Show config load notifications (default: on) controls whether the plugin shows Obsidian Notice popups when vimrc or init.lua files are loaded on startup. When disabled, success and informational notifications (“loaded N commands from …”, “loaded but contained no commands”, “no config files found”) are suppressed. Error notifications (lua syntax/runtime errors) and single-mode “not found” warnings (e.g. configMode is lua but no init.lua exists) always show regardless of this setting.
    • Plugin: src/settings.ts (showConfigNotifications in VimMotionsSettings interface, DEFAULT_SETTINGS, toggle in Vimrc & key bindings group), src/main.ts (notification gating in vimrc loading, lua loading, and dual-mode fallback)

Changed

  • Config load notifications scoped and improved — startup notifications for vimrc and init.lua loading are now better scoped. “Not found” messages only appear when the specific config type is the sole configured mode (e.g. configMode is vimrc but no vimrc exists) and now include the searched path. In dual-mode (lua-vimrc), “no config files found” lists both searched paths. Success and empty-file notifications respect the new showConfigNotifications setting. Error notifications (lua parse/runtime errors) always show.
    • Plugin: src/main.ts (vimrc notification block, lua notification block, dual-mode fallback notification)
  • Picker preview pane renders markdown — full-file picker preview windows now render file content through Obsidian’s MarkdownRenderer.render() instead of displaying raw markdown text in <pre><code> blocks. Headings, bold, italic, code blocks, images, links, callouts, and other markdown formatting are fully rendered. Links inside the preview are non-interactive (click-through disabled via pointer-events: none). Positional previews (grep, live grep, headings, marks) use a line-number gutter that highlights the target line. Component lifecycle is managed per preview update (load() on render, unload() on preview change and modal close) to prevent memory leaks. Plain-string previews (commands, registers) remain unchanged. The picker modal now uses a fixed height (50vh) to prevent layout shifts when switching between files, and the result count element reserves its line height when empty.
    • Plugin: src/picker/picker.ts (renderMarkdownPreview method, Component lifecycle, PreviewResult dispatch), src/picker/types.ts (PreviewResult interface, PreviewReturn union type), src/picker/sources/preview-utils.ts (returns PreviewResult with sourcePath and optional lineRange), styles.css (rendered preview content, positional gutter, fixed modal height)

Fixed

  • Cursor-aware table widget does not render inline markdown — images, bold, italic, math, links, and other inline formatting inside table cells were displayed as plain text when the cursor-aware table widget was active. The TableRenderWidget used textContent to populate cells, which strips all markup. Replaced with MarkdownRenderer.render() to process cell content through Obsidian’s markdown pipeline. Plain text is shown instantly as a fallback while the async render completes. The <p> wrapper added by MarkdownRenderer is unwrapped to avoid block-level spacing in cells. Component lifecycle is managed per widget (load() in toDOM, unload() in destroy) to prevent memory leaks. editorInfoField provides app and sourcePath from the editor state for correct relative image path resolution. (#50)
    • Plugin: src/vim/table-render-widget.ts (renderCell function, MarkdownRenderer.render() integration, Component lifecycle, editorInfoField for app/sourcePath access)
  • :obcommand unavailable in Lua-only config mode — vim.cmd('obcommand ...') failed with “Not an editor command” when configMode was set to lua (without vimrc). The obcommand ex command was only registered inside registerVimrcExCommands(), which only runs when vimrc loading is enabled. Moved obcommand registration to registerObCommand() alongside ob, sharing the same handler. Both commands are now available in all config modes (lua, vimrc, lua-vimrc, settings-only). Additionally, :obcommand with no arguments now opens the command picker (matching :ob behavior) instead of silently doing nothing.
    • Plugin: src/workspace/commands.ts (registerObCommand registers both ob and obcommand), src/vimrc/loader.ts (removed duplicate obcommand registration and unused executeCommandById helper)

Documentation

  • docs/configuration/settings.md: added Mobile section with enableOnMobile setting; added showConfigNotifications toggle to Vimrc & key bindings table
  • docs/getting-started/installation.md: added Mobile section with enable instructions
  • KNOWN_LIMITATIONS.md: updated Mobile support section with opt-in setting, toggle command, and revised platform feature table; added config load notification scoping section under Config file resolution
  • 7 new e2e tests — config-notifications.e2e.ts covering: lua loaded notification shown/suppressed, lua error notification always shown even when suppressed, lua empty-file notification shown/suppressed, notification includes config file path, setting default verification
  • Shared test helpers — setPluginSetting, getNotices, getVimMotionsNotices, dismissNotices added to test/helpers.ts

[0.41.0] - 2026-07-08

Added

  • External config file paths (desktop only) — custom vimrc and init.lua paths now accept absolute filesystem paths (e.g. ~/.config/obsidian/init.lua, C:\Users\<you>\.config\obsidian\vimrc), enabling shared config across multiple vaults. Paths starting with /, ~, or a drive letter are read directly from the filesystem via window.require instead of the vault adapter. Tilde (~) is expanded to the user’s home directory. Mobile gracefully falls back to vault-only paths. (#51)
    • Plugin: src/util/external-fs.ts (new module: isAbsolutePath, readExternalFile, externalFileExists, expandTilde, getObsidianUserDataDir), src/lua/loader.ts (fileExists/readLuaFile external path fallback), src/vimrc/loader.ts (fileExists/readVimrcFile external path fallback), src/settings.ts (updated descriptions)
  • Unified picker / fuzzy finder — telescope.nvim-inspired fuzzy picker with 11 sources, preview pane, live grep, frecency scoring, and split-open support
    • 10 built-in sources: files (:files), buffers (:buffers), commands (:commands), headings (:headings), outline (:outline), backlinks (:backlinks), tags (:tags), recent files (:recent), marks (:marks), registers (:registers)
    • Live grep (:livegrep): real-time vault content search with 200ms debounce, generation-based cancellation, and minimum 2-character query
    • Preview pane: side-by-side file content preview with per-source content (file content, surrounding lines for headings/grep/marks, command info, register content), responsive collapse on narrow screens (<600px), <C-d>/<C-u> preview scrolling
    • Frecency scoring: recently/frequently accessed items rank higher. Time-bucket weights (1h–30d), 1000-entry cap, persists across restarts via plugin data. Applies to files, buffers, commands, headings, backlinks, grep, recent.
    • Picker resume: :resume / <leader>fp / vim.obsidian.pick('resume') reopens the last picker with the same query and selection
    • Split-open: <C-x> (horizontal split), <C-v> (vertical split), <C-t> (new tab) from any file-based picker
    • Leader mappings: 11 <leader>f* bindings with which-key “Find” group (opt-out via pickerLeaderMappings setting, default: on)
    • Keyboard navigation: <C-n>/<C-p>, <C-j>/<C-k>, arrows, <Enter>, <Escape>, <C-c>
    • Matching engine: uFuzzy (7.5KB, unicode support) with match highlighting
    • Fallback setting: picker boolean (default: true) — when disabled, migrated commands (:buffers, :marks, :registers, :grep, :backlinks, :ob) fall back to previous VimInfoModal/SuggestModal behavior
    • Lua API: vim.obsidian.pick(source, opts?) — invoke any picker source from Lua
    • Obsidian command palette: 12 picker commands registered via addCommand for discoverability
    • Global ex command support: all picker commands available in non-editor views via : global ex command modal
    • 200-item render cap with requestAnimationFrame-free synchronous rendering for flicker-free updates
    • Plugin: src/picker/ (picker.ts, matcher.ts, registry.ts, frecency.ts, types.ts, sources/*.ts), src/picker/sources/ (files, buffers, commands, grep, live-grep, headings, backlinks, tags, recent, marks, registers, split-open, preview-utils)
  • isEasyMotionActive() guard — exported from src/easymotion/register.ts to prevent picker from opening during EasyMotion label selection
    • Plugin: src/easymotion/register.ts

Changed

  • :buffers / :ls now opens fuzzy picker instead of VimInfoModal table (when picker setting enabled)
  • :marks now opens fuzzy picker with jump-to-mark action (when picker setting enabled)
  • :registers now opens fuzzy picker with paste-at-cursor action (when picker setting enabled)
  • :ob (no args) now opens commands picker instead of VimInfoModal command list (when picker setting enabled)
  • :grep (no args) now opens live grep picker instead of showing “Usage” notice (when picker setting enabled)
  • :grep <query> now opens picker with pre-computed results instead of SuggestModal (when picker setting enabled)
  • :backlinks now opens fuzzy picker instead of VimInfoModal table (when picker setting enabled)
  • Bundle size: +17.5KB from uFuzzy dependency (unicode mode)

Documentation

  • docs/features/ex-commands.md: added picker commands section
  • docs/reference/keybindings.md: added picker ex commands and <leader>f* mappings
  • docs/configuration/lua-config.md: added vim.obsidian.pick() API documentation; added “Shared config across vaults” subsection documenting external path support
  • docs/configuration/vimrc.md: added “Shared config across vaults” subsection documenting external path support
  • docs/configuration/settings.md: updated custom path descriptions to mention absolute path support
  • KNOWN_LIMITATIONS.md: added picker section with limitations; updated config file resolution section with external path support

[0.40.0] - 2026-07-07

Added

  • vim.keymap.set leader bindings appear in which-key — leader-prefixed keymaps registered via vim.keymap.set with a desc option now automatically appear in the which-key overlay, matching vim.obsidian.leader.add behavior. Group labels from vim.obsidian.whichkey.add() work with both vim.keymap.set and vim.obsidian.leader.add bindings. Buffer-local keymaps (buffer = 0) are excluded from global which-key. (#27)
    • Plugin: src/lua/api.ts (leader prefix auto-detection in vim.keymap.set), src/main.ts (consume luaResult.leaderBindings in LeaderRegistry)
  • Synthetic BufEnter for initial file — BufEnter autocmds now fire for the file already open when the plugin loads, matching Neovim behavior. Previously, BufEnter only fired on subsequent file opens.
    • Plugin: src/lua/autocmd.ts (activate() accepts initialFilePath), src/lua/loader.ts (passes current file path)

Fixed

  • vim.cmd() broken at runtime — vim.cmd() called from function-mapped keymaps, autocmd callbacks, timer callbacks, and user commands silently failed because commands were queued but never executed after initial load. Fixed with a runtimeExHandler that executes commands immediately via vim.handleEx(). Cleanup on plugin unload prevents stale callbacks. (#49, #27)
    • Plugin: src/lua/loader.ts (runtimeExHandler, activateRuntimeExHandler, deactivateRuntimeExHandler), src/main.ts (wire runtime handler, cleanup in onunload)
  • Function-callback keymaps lost after feature reload — vim.keymap.set with function callbacks registered keymaps that were silently destroyed when reloadFeatures() called vim.resetKeymap(). String-RHS keymaps survived but function callbacks did not. Fixed by moving applyLuaMaps() to run after reloadFeatures() and clearing luaActionNames in loadLuaConfigForTest().
    • Plugin: src/main.ts (applyLuaMaps ordering, loadLuaConfigForTest cleanup)
  • Space as leader key breaks which-key — vim.g.mapleader = " " with which-key in “all” mode now works correctly: space doesn’t move the cursor, bindings execute, and grouped which-key displays. The “leader-only” mode still has a known limitation (see KNOWN_LIMITATIONS.md). (#49)
  • Surround nvim-surround parity (19 golden test fixes) — comprehensive alignment with nvim-surround semantics. Golden comparison tests passing: 54 → 73 out of 74. (#41)
    • ds} / ds] / ds) / ds> now preserve inner spaces (only opening bracket forms ds{ / ds[ / ds( / ds< strip spaces)
    • csbBysaBb chain — _surroundType gating prevents stale replacement leaking across different surround operation types
    • csba.. dot-repeat — search position offset by replacement delimiter width for correct nested pair iteration
    • dsb on multiline content — cursor clamped to valid line length after bracket deletion
    • Count-prefixed ds/cs (2dsb, 3dsb, 2csbB, 3csbr) — changed from “find Nth pair” to “apply N times” semantics, matching nvim-surround
    • ys with line-crossing motions (ysjb, ys2jB) — linewise motions now expand range to full lines
    • ySS/VSB/cS/yS/gS newline indentation — single-line content no longer gets extra 2-space indent, matching nvim-surround
    • VS (linewise visual surround) — selection expanded to full lines, uses newline wrapping mode
    • Visual block Ctrl-V $ S} — each line wrapped individually instead of entire block
    • dsf — new operator: delete surrounding function call (some_func(args) → args), with nested call support
    • Fork: src/vim.js — deleteSurroundPair space/cursor, findSurroundingFunction, count loops, linewise/block visual handling, _surroundType dot-repeat isolation
    • Fork: src/types.ts — _surroundType field on InputStateInterface

Documentation

  • docs/configuration/lua-config.md: added leader key subsection with vim.g.mapleader examples and ordering warning; added tip callout comparing vim.cmd() vs vim.obsidian.leader.add() for leader bindings
  • docs/configuration/which-key.md: added “Automatic labels from vim.keymap.set” section documenting desc option integration with which-key and group label composition with wk.add()
  • KNOWN_LIMITATIONS.md: added 7 Lua runtime entries (4 fixed, 3 open); updated test coverage (9 → 43 e2e tests); updated surround parity section
  • 34 new e2e tests across 4 suites — lua-runtime.e2e.ts (8 tests: runtime vim.cmd execution from all callback contexts), lua-leader-whichkey.e2e.ts (9 tests: leader binding registration and which-key integration), lua-space-leader.e2e.ts (7 tests: space as leader key with regression coverage), lua-doc-examples.e2e.ts (10 tests: every documented Lua runtime callback example)
  • Shared test helpers extracted — loadLuaConfig, focusEditor, setWhichKeyMode, hasWhichKeyOverlay, waitForWhichKey, getWhichKeyKeys, getWhichKeyDescriptions, getWhichKeyGroups, getLeaderBindings, getLeaderKey, getPluginSetting moved to test/helpers.ts from local definitions

[0.39.0] - 2026-07-06

Added

  • vim.ob.* API expansion (47 new functions across 4 sub-namespaces) — the vim.obsidian / vim.ob Lua namespace grows from 21 to 68 functions
    • Leaf introspection (Tier 1): vim.ob.get_leaf_type() returns the active view type string, vim.ob.get_active_leaf() returns {id, type, pinned, file_path} table, vim.ob.list_leaves() returns all open tabs, vim.ob.is_markdown_view() returns boolean
    • Command wrappers (Tier 2): vim.ob.follow_link(), vim.ob.backlinks(), vim.ob.daily(), vim.ob.search(), vim.ob.tags(), vim.ob.new_note(), vim.ob.rename(), vim.ob.toggle_checkbox(), vim.ob.template() — thin wrappers around Obsidian commands, silent no-op if required core plugin is disabled
    • Leaf management (Tier 3): vim.ob.focus(direction) navigates panes ("left", "right", "top", "bottom"), vim.ob.close_leaf() closes active tab, vim.ob.split(direction) splits vertically/horizontally, vim.ob.get_leaf_for_file(path) finds which leaf has a file open
    • vim.ob.meta.* sub-namespace (9 metadata query functions) — read-only access to note metadata via Obsidian’s MetadataCache
      • vim.ob.meta.frontmatter(path?) — returns YAML frontmatter as a Lua table, or nil
      • vim.ob.meta.tags(path?) — returns combined body + frontmatter tags as string[]
      • vim.ob.meta.links(path?) — returns outgoing links as {link, display, original}[]
      • vim.ob.meta.backlinks(path?) — returns source file paths linking to this file as string[]
      • vim.ob.meta.headings(path?) — returns headings as {heading, level}[]
      • vim.ob.meta.embeds(path?) — returns embedded content as {link, display}[]
      • vim.ob.meta.aliases(path?) — returns YAML aliases as string[]
      • vim.ob.meta.tasks(path?) — returns checklist items as {text, status, line}[]
      • vim.ob.meta.lists(path?) — returns all list items as {text, line, indent}[]
      • All functions default to the current file when path is omitted
      • Plugin: src/lua/obsidian-api.ts, src/lua/api.ts, src/lua/loader.ts
    • vim.ob.fs.* sub-namespace (11 vault filesystem functions) — read and write vault files with config-dir guards
      • Read: vim.ob.fs.files(pattern?), vim.ob.fs.all_files(), vim.ob.fs.folders(), vim.ob.fs.exists(path), vim.ob.fs.stat(path?)
      • Write: vim.ob.fs.create(path, content?), vim.ob.fs.write(content) or vim.ob.fs.write(path, content), vim.ob.fs.append(content) or vim.ob.fs.append(path, content)
      • Management: vim.ob.fs.rename(new_path) or vim.ob.fs.rename(path, new_path), vim.ob.fs.move(dest) or vim.ob.fs.move(path, dest) (detects folder dest and appends filename), vim.ob.fs.trash(path?)
      • Write/rename/move/trash operations silently reject paths inside the vault config directory (app.vault.configDir)
      • rename uses fileManager.renameFile() which updates backlinks; trash uses fileManager.trashFile() which respects the user’s trash preference
      • Write operations are fire-and-forget (async internally, Lua returns immediately)
      • All write operations default to the current file when path is omitted
      • Plugin: src/lua/obsidian-api.ts, src/lua/api.ts, src/lua/loader.ts
    • vim.ob.ui.* sub-namespace (4 UI control functions) — control Obsidian UI from Lua
      • vim.ob.ui.sidebar(side, state?) — toggle/open/close sidebar ("left"/"right", optional "open"/"close"/"toggle")
      • vim.ob.ui.command_palette() — open command palette
      • vim.ob.ui.quickswitch() — open quick switcher
      • vim.ob.ui.notice(msg) — alias for vim.notify (convenience for staying in vim.ob namespace)
      • Plugin: src/lua/obsidian-api.ts
    • vim.ob editor state and convenience functions — cursor, selection, mode, and notification access
      • vim.ob.get_cursor() — returns {line, col} (1-indexed, Lua/Neovim convention)
      • vim.ob.set_cursor(line, col) — sets cursor position (1-indexed)
      • vim.ob.get_selection() — returns visual selection text or nil
      • vim.ob.mode() — alias for vim.fn.mode() (convenience)
      • vim.ob.notice(msg) — alias for vim.notify (convenience)
      • Plugin: src/lua/obsidian-api.ts, src/lua/api.ts, src/lua/loader.ts
  • 3 new autocmd events — LeafEnter, LeafLeave, FileType (total: 15 events)
    • LeafEnter — fires when a new leaf gains focus (debounced 50ms), event data includes {type, leaf_id} in ev.data
    • LeafLeave — fires when a leaf loses focus (immediate, before LeafEnter)
    • FileType — fires after BufEnter with ev.match set to detected filetype from file extension (.md → "markdown", .ts → "typescript", etc.)
    • Enables Neovim-style per-filetype keymaps: vim.api.nvim_create_autocmd("FileType", { pattern = "markdown", callback = function() ... end })
    • Plugin: src/lua/autocmd.ts (fireFileType, onActiveLeafChange extension), src/main.ts (leaf info passthrough)
  • workspaceNavViewTypes setting — comma-separated list of view types where scroll and count keys are intercepted. Defaults to markdown,graph,pdf,canvas,empty,image. Plugin views not in this list receive their own keystrokes. Configurable via Settings → Vim Motions → Workspace navigation view types, vimrc (set workspacenavviewtypes=...), or Lua (vim.opt.workspacenavviewtypes = "..." or vim.opt.workspacenavviewtypes = {"markdown", "graph", "pdf"})
    • Plugin: src/settings.ts, src/vim/options.ts, src/vimrc/loader.ts
  • vim.opt table (array) support for string options — string-type options can now be set using Lua tables: vim.opt.workspacenavviewtypes = {"markdown", "graph", "pdf"} is equivalent to vim.opt.workspacenavviewtypes = "markdown,graph,pdf". Elements are joined with commas. Applies to all string-type options.
    • Plugin: src/lua/api.ts (vim.opt.__newindex table handling)

Fixed

  • Surround opening bracket semantics (ds(/ds[/ds{/cs({) — findSurroundingBrackets received swapped parameters when the target was an opening bracket ((, [, {, <), causing the backward search to look for the wrong bracket character. ds( on ( hello world ) was a no-op because the search looked for ) going backward. Fixed by detecting opening bracket targets and swapping parameters so the closing bracket is always passed as the forward-search character. Also fixes cs({, ds( on nested/multiline content, and ds<. (#41)
    • Fork: src/vim.js — findSurroundingPair bracket parameter ordering
  • Surround cursor position after ys/yss/visual S — addSurroundToRange placed the cursor at from.ch + pair.open.length (after the opening delimiter). nvim-surround places it at from.ch (on the opening delimiter). Fixed by removing + pair.open.length. The _surroundSelOffset.chDelta used for dot-repeat now adds pair.open.length at recording time to compensate, preserving correct visual surround replay ranges. (#41)
    • Fork: src/vim.js — addSurroundToRange cursor, surroundVisual offset recording
  • Visual-block cursor displaced rightward at end-of-line — in visual-block mode (<C-v>), selecting to the end of a line (via $ or l to EoL) caused the block cursor to render one position past the last visible character. The measureCursor() function in the fork’s block-cursor.ts had a guard (!vim.visualBlock) that prevented the EOL step-back for visual-block mode. This guard was originally correct when makeCmSelection produced toCh + 1 without clamping, but after the per-line clamping fix (issue #38), block selection heads legitimately land on newline positions and need the step-back. Fixed by removing the !vim.visualBlock exclusion. (#41)
    • Fork: src/block-cursor.ts — measureCursor() EOL adjustment guard
  • Visual-block A skips short lines instead of padding — in visual-block mode, A (append) on a block spanning lines shorter than the block column skipped those lines entirely. Neovim pads short lines with spaces to reach the block’s right edge before appending. Fixed by adding a padShortLines parameter to selectForInsert in the fork — A pads, I still skips (matching Neovim). (#41)
    • Fork: src/vim.js — selectForInsert() padding, enterInsertMode passes flag for endOfSelectedArea
  • Visual charwise r replaces one fewer character across line boundary — the replace action in the fork used curEnd = selEnd (the inclusive head position) for charwise visual mode, but cm.getRange() treats the end as exclusive. This caused r <Space> across a line boundary to replace one fewer character than the visual selection covered. Fixed by using selEnd.ch + 1 for the exclusive end. (#41)
    • Fork: src/vim.js — actions.replace charwise visual branch
  • set insertmodeescape=jk leaves j in buffer after escaping insert mode — the InsertEscapeHandler sent vim.handleKey(adapter, '<BS>') to delete typed characters before sending <Esc>, but codemirror-vim does not handle <BS> in insert mode (returns false, expecting the browser default action). Since handleKey is called programmatically with no DOM event, the backspace had no effect and the first character(s) of the escape sequence remained in the buffer. Fixed by replacing the handleKey('<BS>') loop with a direct adapter.replaceRange() call that deletes exactly escapeSeq.length - 1 characters before the cursor (the last key in the sequence is already intercepted by preventDefault and never enters the document). The native imap jk <Esc> mapping (via vimrc or vim.map()) was unaffected — codemirror-vim’s changeQueue cleanup handles that path correctly.
    • Plugin: src/vim/insert-escape.ts (onKeyDown method)
  • scrolloff values above ~30 pin view at bottom of document — high scrolloff values (e.g., set scrolloff=999 to center the cursor) caused the viewport to pin at the top or bottom instead of centering. The scroll margin was passed to CodeMirror’s EditorView.scrollMargins unclamped, producing a target rect taller than the viewport. CM6’s scrollRectIntoView resolved the conflicting top/bottom constraints by favoring one side based on cursor direction. Fixed by clamping the margin to half the viewport height, mirroring Vim’s silent cap of scrolloff to (window_height - 1) / 2. (#48)
    • Plugin: src/vim/scrolloff.ts — createScrolloffExtension() viewport-relative clamp
  • Workspace navigation intercepting keystrokes in plugin leaves — when workspace navigation was enabled, the global key handler consumed keystrokes (1, 2, 3, 0, j, k, etc.) in non-editor plugin views (Spaced Repetition, Excalidraw, etc.) before the plugin could process them. Fixed with a three-gate interception system: structural keys (<C-w>*, gt/gT, <C-o>/<C-i>, :) always work in non-editor views, content keys (scroll, digits, tab shortcuts) only intercept in whitelisted view types, and plugin views receive their own keystrokes. (#47)
    • Plugin: src/workspace/global-mapping-registry.ts (GlobalMapGate → 'standard' | 'hint' | 'structural'), src/workspace/global-defaults.ts (gate assignments), src/workspace/global-key-handler.ts (three-gate onKeydown rewrite, GLOBAL_NAV_VIEW_TYPES whitelist, shouldInterceptContent/shouldInterceptStructural methods)

Changed

  • minAppVersion bumped from 1.4.10 to 1.6.6 — required for Vault.getAllFolders() used by vim.ob.fs.folders()
  • vim.obsidian.* namespace extracted to dedicated module — the Obsidian-specific Lua API is now in src/lua/obsidian-api.ts (extracted from api.ts), following the pattern of fn.ts, stdlib.ts, timers.ts, highlight.ts. No behavioral change. api.ts shrinks by ~504 lines.

Documentation

  • docs/configuration/lua-config.md: added vim.ob.meta.* (9 functions), vim.ob.fs.* (11 functions), vim.ob.ui.* (4 functions), editor state functions (5 functions) with API tables and examples
  • KNOWN_LIMITATIONS.md: added “Workspace navigation in plugin views” section documenting three-gate interception and the gg-in-plugin-leaf trade-off; marked #47 as fixed; added “Neovim golden test coverage gaps” section documenting non-verifiable areas (scroll/viewport, fold, jumplist, cursor rendering); updated visual mode EOL cursor section with visual-block A padding and visual r off-by-one fixes
  • Neovim golden test coverage expansion — 106 new golden comparison test cases across 6 suites, recorded against Neovim 0.12.2:
    • surround (74 cases): comprehensive nvim-surround parity — ds/cs/ys/yss/visual S with all delimiter types, count-prefixed operations (2dsb, 2csbB), dot-repeat (ysiwb.., dsb.., csba..), tag surround (dst, cst), function surround (dsf), ysa (around surround), empty content, whitespace cascade (ds{ strips / ds} preserves), motion-based (ys$, ysjb), newline variants (ySS, VSB), angle brackets, arbitrary delimiters (|, ^), multiline, nesting, and cursor positioning. Ground truth shifted from tpope/vim-surround to nvim-surround — better maintained, comprehensive test suite, Lua-native, superset of tpope behavior. 54 pass, 20 tracked deviations.
    • dot-repeat (17 cases): . after 2dw, dd, 3i, 3o, cw, R, 2dl, d2w, g~2w, V>, 3J, 3I, visual block ~, o
    • select-mode-extended (6 cases): gh/gH enter select, type replaces, <BS> deletes, <Esc> exits, <C-g> toggles visual↔select
    • ex-sort (6 cases): :sort, :sort!, :sort i, :sort u, :sort n, :2,3sort
    • ex-global (3 cases): :g/pattern/d, :v/pattern/d, :g/a/s/a/x/
    • upstream-gaps (7 cases): dip paragraph, backward block A, block A short-line padding, visual r cross-line, block↔char/line mode switch, macro replay
    • Test infrastructure: SuiteDefinition.nvimSetup field for per-suite Neovim commands (loads nvim-surround for surround suite), NeovimClient.executeCommand() method
    • Total golden test coverage: 276 → 382 cases across 28 suites
  • docs/configuration/settings.md: added Workspace navigation view types to Vim features table
  • docs/configuration/vimrc.md: added workspacenavviewtypes (wnvt alias) to string options table
  • docs/configuration/lua-config.md: added 17 new vim.ob.* functions, 3 new autocmd events (LeafEnter, LeafLeave, FileType), workspacenavviewtypes option
  • docs/features/workspace-navigation.md: added “Plugin view compatibility” section with key passthrough table and whitelist customization
  • docs/guides/ecosystem-compatibility.md: added “Plugin leaf key passthrough” section
  • docs/configuration/lua-config.md: added table (array) syntax tip for string options with example

[0.38.0] - 2026-07-06

Added

  • Custom surround pairs (vim.obsidian.surround / surroundmap) — define custom single-character triggers that map to arbitrary delimiter strings, with full ys/ds/cs support including multi-character delimiters (#36)
    • vim.obsidian.surround.set("l", { left = "[[", right = "]]" }) — register a custom pair
    • vim.obsidian.surround.del("l") — remove a custom pair
    • vim.obsidian.surround.add({ { "l", left = "[[", right = "]]" }, { "m", left = "$$", right = "$$" } }) — batch registration
    • Vimrc: surroundmap l [[ ]] / surroundunmap l
    • Reserved characters (`( ) [ ] { } < > b B r a t T f F ” ’ “) are rejected with a descriptive error
    • Requires fork mode (bundled vim engine) — custom pairs are registered via Vim.registerSurroundPair() on the codemirror-vim fork
    • Fork: customSurroundPairs registry, findSurroundingMultiChar() algorithm for multi-char delimiter matching, openWidth/closeWidth support in deleteSurroundPair/changeSurroundPair
    • Plugin: src/lua/api.ts (vim.obsidian.surround sub-table), src/vimrc/parser.ts + src/vimrc/loader.ts (surroundmap/surroundunmap commands), src/main.ts (applyLuaSurroundPairs lifecycle)
  • vim.obsidian.cursor.set() — structured cursor shape configuration — set per-mode cursor shapes via a Lua table instead of the guicursor format string
    • vim.obsidian.cursor.set({ normal = "block", insert = "bar", operator_pending = "underline" }) — partial tables allowed
    • Valid shapes: block, bar, underline, hollow
    • Equivalent to vim.opt.guicursor but uses a table API
    • Plugin: src/lua/api.ts (onCursorConfig callback, vim.obsidian.cursor sub-table)
  • vim.obsidian.modeprompt.set() — batch mode prompt configuration — set status bar mode text for multiple modes in a single call
    • vim.obsidian.modeprompt.set({ normal = "NOR", insert = "INS", visual_line = "V-LN" }) — partial tables allowed
    • 11 mode keys supported with snake_case Lua names mapped to camelCase settings keys
    • Equivalent to setting individual vim.g.mode_prompt_* variables
    • Plugin: src/lua/api.ts (onModePromptConfig callback, vim.obsidian.modeprompt sub-table)
  • vim.obsidian.leader.set() — leader binding convenience API — bind leader key sequences to Obsidian commands with automatic :ob prefix, leader key prepend, and which-key label registration
    • vim.obsidian.leader.set("e", "file-explorer:reveal-active-file", { desc = "Reveal" }) — single binding
    • vim.obsidian.leader.add({ { "ff", "switcher:open", desc = "Find file" } }) — batch registration
    • desc option auto-registers a which-key command label
    • For general-purpose keymaps or Lua callbacks, use vim.keymap.set instead
    • Plugin: src/lua/api.ts (onLeaderBinding/onLeaderBindingDel callbacks, vim.obsidian.leader sub-table)

Fixed

  • vim.g.mode_prompt_* read returns nil for settings-UI-set values — the getModePrompt callback was defined in the VimApiCallbacks interface but not wired up in loader.ts. Reading vim.g.mode_prompt_normal returned nil unless the value was also set via vim.g in the same init.lua session. Fixed by implementing the callback in the loader.
    • Plugin: src/lua/loader.ts (getModePrompt callback)

Documentation

  • docs/configuration/lua-config.md: added 4 new Obsidian namespace sections — cursor shapes (vim.obsidian.cursor), mode prompts (vim.obsidian.modeprompt), custom surround pairs (vim.obsidian.surround), leader bindings (vim.obsidian.leader) — with API tables, examples, and cross-references
  • KNOWN_LIMITATIONS.md: updated Lua supported APIs list to include vim.obsidian.cursor.set, vim.obsidian.modeprompt.set, vim.obsidian.surround.set/del/add, vim.obsidian.leader.set/del/add; updated surround section with custom pairs documentation and issue #36 reference

[0.37.0] - 2026-07-06

Added

  • vim.obsidian.whichkey.add() — batch which-key label configuration — define multiple group and command labels in a single call, similar to Neovim’s which-key.nvim wk.add() syntax (#27)
    • vim.obsidian.whichkey.add({ { "<leader>f", group = "Find" }, { "<leader>w", desc = "Save" } }) — each entry uses group for prefix labels or desc for individual binding labels
    • Per-entry context field: "editor" (default) or "global" for non-editor which-key overlay
    • mode field accepted but reserved for future mode-scoped label support
    • Entries without a key string or without group/desc are silently skipped
    • Shorthand: local wk = vim.obsidian.whichkey; wk.add({ ... }) for Neovim-familiar syntax
    • Plugin: src/lua/api.ts (vim.obsidian.whichkey.add), src/lua/types.d.ts (luaL_len type)

Changed

  • Config file fallback chains — vimrc and Lua config files are now resolved via a fallback chain instead of a single hardcoded path. The plugin searches the vault root for the first matching file. Custom path overrides still take priority.
    • Vimrc chain (8 candidates): vimrc, .vimrc, init.vim, .init.vim, obsidian.vimrc, obsidian.vim, .obsidian.vimrc, .obsidian.vim
    • Lua chain (5 candidates): init.lua, .init.lua, obsidian.init.lua, .obsidian.init.lua, obsidian.lua
    • Non-dotfile names (vimrc, init.lua) are preferred — Obsidian Sync skips dotfiles, and the .obsidian.* naming relied on a linter workaround
    • Settings UI now shows “Currently using: {path}” (resolved path) or “File not found” for invalid custom paths
    • Settings descriptions list the full fallback chain
    • Backward compatible: existing .obsidian.vimrc and .obsidian.init.lua files still work (they appear later in the chain)
    • Plugin: src/vimrc/loader.ts (resolveVimrcPath, VIMRC_FALLBACK_PATHS), src/lua/loader.ts (resolveLuaConfigPath, LUA_FALLBACK_PATHS), src/settings.ts (async path resolution display), styles.css (.vim-motions-config-path-active/.vim-motions-config-path-error classes)

Documentation

  • docs/configuration/vimrc.md: file location section rewritten with full fallback chain table
  • docs/configuration/lua-config.md: file location section rewritten with full fallback chain table; added vim.obsidian.whichkey.add() to API summary table and Obsidian namespace section with wk.add() example
  • docs/configuration/settings.md: custom path setting descriptions updated with fallback chain lists
  • docs/configuration/which-key.md: added “Batch labels (add())” section with Neovim-style wk.add() syntax, local wk shorthand tip, and reserved mode field callout
  • docs/guides/migrating-from-vimrc-support.md: custom vimrc path section updated with fallback chain
  • KNOWN_LIMITATIONS.md: updated supported Lua APIs list to include vim.obsidian.whichkey.add()

[0.36.0] - 2026-07-06

Added

  • vim.obsidian.keymap — global (non-editor) keymaps from Lua — define key bindings for non-editor contexts (graph view, canvas, PDF viewer, file explorer) using a Neovim-style API
    • vim.obsidian.keymap.set(lhs, rhs, opts?) — create a global keymap with :obcommand <id> or :<ex-command> as RHS
    • vim.obsidian.keymap.del(lhs) — remove a global keymap
    • desc option auto-creates a label in the global which-key popup
    • Lua global keymaps override vimrc gmap on conflict (last-write-wins)
    • Survives settings changes and feature reloads via luaGlobalMaps persistence arrays
    • Plugin: src/lua/api.ts (LuaGlobalKeymap type, onGlobalKeymap/onGlobalKeymapDel callbacks)
  • vim.obsidian.whichkey — which-key labels from Lua — set group and command labels for the which-key popup
    • vim.obsidian.whichkey.set_group(key, label, opts?) — name a which-key group by prefix
    • vim.obsidian.whichkey.set_label(key, label, opts?) — label an individual which-key binding
    • context option defaults to "editor"; use { context = "global" } for non-editor which-key overlay
    • Previously only available via vimrc whichkeygroup/whichkeylabel and Settings UI
    • Plugin: src/lua/api.ts (onWhichKeyGroupLabel/onWhichKeyCommandLabel callbacks)
  • vim.opt.guicursor — cursor shapes from Lua — set per-mode cursor shapes without vim.cmd passthrough
    • vim.opt.guicursor = "n:block,i:bar,v:block,r:underline,o:underline" — mode codes: n, i, v, r, o, a (all); shapes: block, bar, underline, hollow
    • Write-only (reading returns nil); invalid strings log a warning
    • Previously only available via vimrc set guicursor=...
  • Lua standard library utilities (vim.tbl_*, vim.split, vim.inspect, vim.json) — 22 Neovim-compatible utility functions for table manipulation, string operations, debugging, and JSON serialization
    • Table utilities (12): vim.tbl_deep_extend, vim.tbl_extend, vim.tbl_contains (with predicate support), vim.tbl_keys, vim.tbl_values, vim.tbl_map, vim.tbl_filter, vim.tbl_count, vim.tbl_isempty, vim.tbl_get, vim.list_extend, vim.deepcopy
    • String utilities (6): vim.split (with {plain, trimempty} options), vim.trim, vim.startswith, vim.endswith, vim.pesc, vim.stricmp
    • vim.inspect(value) — human-readable table/value serialization for debugging init.lua configs
    • vim.json.encode(value) / vim.json.decode(str) — JSON serialization bridged to JavaScript’s JSON.stringify/JSON.parse
    • Plugin: src/lua/stdlib.ts
  • Async primitives (vim.schedule, vim.defer_fn, vim.uv timers) — Neovim-compatible async APIs for deferred execution and timer management
    • vim.schedule(fn) — defer function to next event loop iteration (useful for breaking recursive autocmd loops)
    • vim.schedule_wrap(fn) — returns a function that wraps fn with vim.schedule, passing all arguments
    • vim.defer_fn(fn, timeout) — defer function by timeout milliseconds, returns cancellable handle with stop()/close()/is_closing()
    • vim.uv.new_timer() — create timer with start(delay, repeat, callback), stop(), close(), is_closing(), is_active()
    • vim.uv.hrtime() — high-resolution time in nanoseconds
    • vim.uv.now() — current time in milliseconds
    • vim.loop alias for vim.uv (Neovim backward compatibility)
    • All timers cleaned up on plugin unload (no leaked timeouts)
    • Plugin: src/lua/timers.ts
  • Buffer-local keymaps (vim.keymap.set({ buffer = 0 })) — keymaps scoped to specific files, automatically swapped on editor/tab switch
    • vim.keymap.set("n", "gd", handler, { buffer = 0 }) — keymap active only in the current file
    • vim.api.nvim_buf_set_keymap(0, mode, lhs, rhs, opts) / nvim_buf_del_keymap(0, mode, lhs) — low-level buffer keymap APIs
    • Combined with BufEnter autocmd for per-filetype keymaps (e.g., markdown-only bindings)
    • Buffer identity uses vault-relative file path; only buffer = 0 (current file) is supported
    • Plugin: src/lua/buffer.ts (BufferKeymapManager)
  • Buffer content APIs (nvim_buf_get_lines, nvim_buf_set_lines) — read and modify editor content from Lua callbacks
    • vim.api.nvim_buf_get_lines(0, start, end, strict_indexing) — 0-based, end-exclusive, -1 for EOF
    • vim.api.nvim_buf_set_lines(0, start, end, strict_indexing, replacement) — empty table deletes lines
    • vim.api.nvim_get_current_buf() — returns 0 (current buffer)
    • vim.api.nvim_buf_get_name(0) — vault-relative file path
    • vim.api.nvim_buf_line_count(0) — total line count
    • strict_indexing = true errors on out-of-bounds; false clamps silently
  • 4 new autocmd events — CursorMoved, CursorHold, BufWritePre, BufWritePost (total: 12 events)
    • CursorMoved — fires after cursor moves (throttled via vim-command-done event)
    • CursorHold — fires after cursor is idle for updatetime ms (default 4000, configurable via vim.opt.updatetime)
    • BufWritePre / BufWritePost — fire before/after :w, :wq, :x, :wall, :update with vault-relative glob pattern support
    • updatetime option added to KNOWN_SET_OPTIONS for vimrc and vim.opt configuration
  • vim.obsidian namespace (vim.ob alias) — Obsidian-specific APIs that don’t exist in Neovim
    • vim.obsidian.vault_name(), vim.obsidian.app_version(), vim.obsidian.plugin_version()
    • vim.obsidian.run_command(id) — execute any Obsidian command by ID
    • vim.obsidian.list_commands() — table of {id, name} for all available commands
    • vim.obsidian.open_file(path) — open a vault file
    • vim.obsidian.current_file() — table {path, name, extension, basename} or nil
    • vim.obsidian.vault_path() — vault absolute path (desktop only)
  • Sandboxed vim.env — environment variable proxy with curated values and user-defined storage
    • vim.env.HOME (vault path), vim.env.VIM ("motions"), vim.env.TERM ("obsidian"), vim.env.OBSIDIAN_VERSION
    • Custom variables: vim.env.MY_VAR = "value" — stored in memory, not in process.env
    • Unknown keys return nil
  • vim.api.nvim_set_hl — highlight group → CSS bridge — customize plugin styling from Lua using Neovim’s highlight API
    • vim.api.nvim_set_hl(0, "EasyMotionTarget", { fg = "#ff5555", bold = true }) — change EasyMotion label colors
    • vim.api.nvim_set_hl(0, "StatusLineNormal", { bg = "#282a36" }) — change status bar mode colors
    • 13 plugin-defined highlight groups: EasyMotionTarget, EasyMotionShade, HintTarget, StatusLineNormal/Insert/Visual/Replace/VLine/VBlock/Command/Search/Select/VReplace
    • User-defined groups generate .vim-hl-GroupName CSS classes
    • Supports: fg, bg, sp, bold, italic, underline, undercurl, strikethrough, reverse, blend, link (group inheritance), default (don’t override), update (merge)
    • vim.api.nvim_get_hl(0, { name = "group" }) — query highlight attrs
    • vim.api.nvim_create_namespace(name) — returns 0 (only global namespace supported)
    • Plugin: src/lua/highlight.ts (HighlightManager)
  • Enhanced vim.notify with log levels — vim.notify(msg, level) routes messages by severity
    • vim.log.levels: TRACE (0), DEBUG (1), INFO (2), WARN (3), ERROR (4), OFF (5)
    • ERROR/WARN → Obsidian Notice + console; INFO → Notice; DEBUG/TRACE → console.debug only
    • vim.notify_once(msg, level) — deduplicates by message content

Fixed

  • Space-leader global keymaps not matching keyboard input — replaceLeaderKey converted <leader> to raw " " (space character), but normalizeKeyEvent in GlobalKeyHandler converted spacebar to "<Space>". The key sequences never matched in GlobalMappingRegistry.resolve(). Fixed by adding normalizeKeyString() to convert raw special characters to angle-bracket notation (" " → "<Space>") before storing keys in the registry. Affects both vimrc gmap and Lua vim.obsidian.keymap.set with space leader.
    • Plugin: src/workspace/global-mapping-registry.ts (normalizeKeyString), src/main.ts (applyGlobalMaps, rebuildGlobalWhichKey)
  • vim.g.mode_prompt_* reads returned nil after write — the __newindex handler for mode_prompt keys called onSettingOverride but did not store the value in the globals Map. The __index handler’s fallback to globals.get(key) returned undefined. Fixed by also storing in globals on write.
    • Plugin: src/lua/api.ts (vim.g __newindex handler)

Changed

  • vim.api expanded from 6 to 16 functions — nvim_set_hl, nvim_get_hl, nvim_create_namespace, nvim_buf_get_lines, nvim_buf_set_lines, nvim_get_current_buf, nvim_buf_get_name, nvim_buf_line_count, nvim_buf_set_keymap, nvim_buf_del_keymap added alongside existing autocmd/augroup/user command functions
  • Autocmd events expanded from 8 to 12 — added CursorMoved, CursorHold, BufWritePre, BufWritePost
  • vim.obsidian namespace expanded — added keymap and whichkey sub-namespaces for global keymaps and which-key labels. vim.ob alias includes the new sub-namespaces.

Documentation

  • docs/configuration/lua-config.md: comprehensive Lua API reference expansion — added vim.opt table with defaults and valid ranges, keymapping mode reference, autocmd event data reference (per-event ev.data fields), highlight group CSS variable mapping, Lua sandbox reference (available/unavailable libraries, instruction limits), vim.fn.has() completeness statement, mode prompt customization section, global keymaps section (vim.obsidian.keymap), which-key labels section (vim.obsidian.whichkey), vim.opt.guicursor option; fixed buffer option row (was “Not supported”, now correctly documents buffer = 0/true); fixed os/debug library availability claims (not loaded by plugin); added vim.stricmp, vim.env.MYVIMRC, underdouble/underdotted/underdashed highlight attributes, TextYankPost regname field, highlight group case-sensitivity callout, buffer-local keymap accumulation warning, underline style limitation callout
  • docs/configuration/which-key.md: added Lua examples for group labels (vim.obsidian.whichkey.set_group) and global which-key labels
  • docs/configuration/cursor-shapes.md: added vim.opt.guicursor Lua section, removed “not supported” workaround note
  • docs/configuration/status-bar.md: expanded Lua mode prompt examples to all 11 modes
  • docs/features/ex-commands.md: added Lua example for custom commands via nvim_create_user_command
  • KNOWN_LIMITATIONS.md: updated supported APIs list (added vim.obsidian.keymap, vim.obsidian.whichkey, vim.opt.guicursor), corrected os/debug library availability (not loaded by plugin sandbox)
  • AGENTS.md: clarified fengari fork vs plugin library loading distinction (fork keeps os/debug, plugin does not load them)
  • README.md: updated tagline and Lua configuration feature bullet with expanded API surface

[0.35.0] - 2026-07-05

Changed

  • Fengari Lua runtime switched to browser-only fork — replaced upstream fengari (v0.1.5) with a browser/Obsidian-only fork that strips all Node.js dependencies. Eliminates community scanner warnings for “Direct Filesystem Access” (require('fs')), “Shell Execution” (require('child_process')), and “System Identity Information” (process.env.USER/HOSTNAME) that originated from fengari’s bundled Node.js code paths (never executed at runtime but present in the bundle). (DIFFERENCES.md)
    • Removed from fork: liolib.js (Lua io library), loadlib.js (Lua package/require() system), Node.js branches from loslib.js/ldblib.js/lauxlib.js/lbaselib.js/luaconf.js
    • Removed npm dependencies: readline-sync, tmp (kept sprintf-js for string.format)
    • Retained browser-safe os library functions: os.date, os.time, os.difftime, os.clock, os.setlocale
    • Retained debug library (minus debug.debug() interactive REPL): debug.traceback, debug.getinfo, debug.sethook, etc.
    • Fixed crash-on-mobile bug: upstream’s unconditional process.env.FENGARICONF access at module load time throws ReferenceError on non-Electron platforms
    • Bundle impact: Fengari runtime reduced from +238KB to +201KB minified (-37KB / -15.5%), +179KB to +165KB gzipped (-14KB / -7.7%)
    • print() now always uses console.log (previously used process.stdout.write in Electron)
    • luaL_loadfilex stubbed to return error (plugin already disabled loadfile/dofile at Lua level)
    • Dependency pattern matches codemirror-vim fork: "fengari": "https://github.com/saberzero1/fengari.git" in package.json

[0.34.0] - 2026-07-05

Added

  • Lua configuration support (.obsidian.init.lua) — optional Neovim-style Lua configuration using a sandboxed Fengari Lua 5.3 runtime. Provides conditional logic, function-based keymaps, and familiar vim.keymap.set / vim.opt syntax. Disabled by default — enable in Settings → Vim Motions → Vimrc & key bindings → Enable Lua configuration. (#46)
    • vim.opt.<name> = value / vim.o.<name> — set any plugin option (backed by the same KNOWN_SET_OPTIONS map as vimrc set commands)
    • vim.g.mapleader / vim.g.<name> — set leader key and user variables
    • vim.keymap.set(mode, lhs, rhs, opts) — key mappings with string or function RHS, desc for which-key labels, noremap/remap control, multi-mode support
    • vim.keymap.del(mode, lhs) — remove mappings
    • vim.cmd(string) — execute ex commands (deferred until first editor focus)
    • vim.vault_name() — returns the current vault name for per-vault conditional config
    • vim.notify(msg) — show an Obsidian notification from Lua
    • print(...) — outputs to developer console
    • Sandbox: 6 defense layers — selective library loading (no io/os/debug/package), dangerous globals stripped (load/dofile/loadfile), no fengari-interop, instruction-count timeout via lua_sethook (1M instruction limit), custom environment table
    • Hybrid loading: settings and keymaps load immediately without an active editor; vim.cmd() calls are queued and executed on first editor focus
    • Override hierarchy: init.lua loads after vimrc — Lua values override vimrc on conflict
    • Settings: configMode dropdown (Lua + Vimrc / Lua only / Vimrc only / Settings only), luaConfigPath (custom file path)
    • Bundle impact: +238KB minified / +79KB gzipped (Fengari runtime)
    • Plugin: src/lua/engine.ts (sandbox + timeout), src/lua/api.ts (vim.* bridge), src/lua/loader.ts (hybrid file loading), src/lua/types.ts (Fengari type declarations)
    • 12 Neovim golden comparison test cases (lua-keymaps suite), 17 e2e integration tests, 4 known deviations registered
  • vim.fn.* Neovim function subset — 27 functions from Neovim’s vim.fn namespace, scoped for Obsidian’s vault-centric environment
    • Config/detection (13): has, expand, fnamemodify, exists, localtime, strftime, filereadable, isdirectory, glob, mode, line, col, getline
    • String manipulation (14): tolower, toupper, trim, strlen, strwidth, stridx, strridx, strpart, substitute, nr2char, char2nr, split, join
    • vim.fn.has(feature) — platform detection with 12 features: mac, linux, win32, unix, mobile, desktop, ios, android, obsidian, obsidian-X.Y, nvim (0), vim (0)
    • vim.fn.expand('%') — vault-relative file path with modifiers (:t, :e, :r, :h, :p)
    • vim.fn.fnamemodify(path, mods) — general-purpose path modifier with chainable modifiers (:t:r)
    • vim.fn.filereadable(path) / vim.fn.isdirectory(path) — vault-scoped, path traversal blocked
    • vim.fn.glob(pattern) — vault-scoped file matching
    • vim.fn.line('.') / vim.fn.col('.') / vim.fn.getline('.') — context-aware: return cursor position in function callbacks, return 0 at config-load time
    • vim.fn.strftime(fmt) — full C89 strftime implementation (src/lua/strftime.ts)
    • Unsupported vim.fn.* functions produce a helpful error listing available functions
    • vim.fn.hostname() / vim.fn.getenv() intentionally skipped (system fingerprinting concern)
    • Plugin: src/lua/fn.ts (VimFnCallbacks, function registry, __index dispatch), src/lua/strftime.ts (pure strftime utility)
  • vim.api.nvim_create_user_command: define custom ex commands from Lua
    • String RHS: vim.api.nvim_create_user_command("W", "w", {}): simple aliases
    • Function RHS: vim.api.nvim_create_user_command("Today", function(opts) ... end, {}): Lua callback with opts.args
    • vim.api changed from error stub to partial namespace: unsupported vim.api.* functions give a helpful error listing nvim_create_user_command as available
    • Registered commands are immediately usable from the : ex command line
  • nvim_create_autocmd / nvim_create_augroup: Neovim-compatible autocommand system with 8 events
    • Events: InsertEnter, InsertLeave, ModeChanged, BufEnter, BufLeave, FocusGained, FocusLost, TextYankPost
    • Augroups with { clear = true } for safe config reloads
    • nvim_del_autocmd, nvim_del_augroup_by_name, nvim_clear_autocmds for management
    • ModeChanged supports "old:new" pattern with * wildcard
    • BufEnter/BufLeave support vault-relative path glob patterns
    • TextYankPost provides structured data: operator, regcontents, regtype, visual
    • Non-nested guard prevents infinite autocmd loops
    • Reentrancy protection: settings changes from callbacks defer reloadFeatures()
    • Plugin: src/lua/autocmd.ts (AutocmdManager class)
    • Fork: vim-yank signal added to yank/delete/change operators in vim.js
    • 16 unit tests, 2 e2e tests
  • Unit test infrastructure — Vitest test runner for the Lua config modules
    • 49 unit tests across 6 files (smoke, sandbox, timeout, api, fn, strftime)
    • Runs in 250ms without Obsidian or browser
    • npm run test:unit / npm run test:unit:watch scripts
    • Obsidian module mocked via test/unit/__mocks__/obsidian.ts
    • CI: .github/workflows/lint.yml now runs unit tests on every push across all branches

Changed

  • Consolidated configuration settings — replaced two independent toggles (enableVimrc + enableLuaConfig) with a single Configuration mode dropdown (configMode):
    • Lua + Vimrc (default): both loaded, Lua overrides vimrc on conflict
    • Lua only: only init.lua loaded
    • Vimrc only: only .obsidian.vimrc loaded
    • Settings only: neither config file loaded
    • Notification logic consolidated: in Lua + Vimrc mode, only notifies when NEITHER file is found (no spam about missing vimrc when only using Lua, or vice versa)
    • Automatic migration from old boolean settings on first load
    • Custom path fields (init.lua path, vimrc path) remain independent and disable based on active mode

Documentation

  • docs/configuration/lua-config.md: full Lua configuration reference with supported APIs, all vim.opt options, vim.fn.* function tables (has features, expand modifiers, fnamemodify modifiers, exists expressions), mapping examples, conditional config examples, loading order, unsupported API documentation
  • docs/configuration/settings.md: updated with configMode dropdown replacing old toggles, added Lua column to all settings tables
  • docs/configuration/index.md: reordered — Lua configuration presented as primary method, vimrc as alternative
  • docs/configuration/vimrc.md: added tip pointing to Lua configuration for advanced use cases
  • docs/configuration/which-key.md: added Lua desc option integration for which-key labels
  • docs/configuration/cursor-shapes.md: added vim.cmd workaround note for guicursor
  • docs/configuration/status-bar.md: added Lua equivalents for status bar settings
  • docs/features/quality-of-life.md: added Lua examples alongside vimrc
  • docs/features/workspace-navigation.md: added Lua examples alongside vimrc
  • docs/getting-started/quickstart.md: reordered — Lua shown as recommended configuration path
  • docs/reference/known-limitations.md: Lua configuration section with supported/unsupported APIs, hybrid loading, vim.fn subset, bundle size
  • KNOWN_LIMITATIONS.md: Lua configuration section with full details

[0.33.0] - 2026-07-05

Fixed

  • Obsidian commands only affect cursor line in visual-line mode (all invocation paths) — the previous fix (0.31.0, fork-side) only covered keyboard events that vim didn’t handle: it expanded the CM6 selection in the fork’s handleKey during the bubble phase. However, Obsidian’s Keymap registers its keydown listener on window in the capture phase (addEventListener("keydown", handler, true)), which fires before CM6’s bubble-phase handler — so commands triggered via Obsidian hotkeys executed with cursor-only selection before the fork could expand it. Additionally, commands invoked via executeCommandById (command palette, toolbar buttons, other plugins) bypassed the DOM event path entirely. Spike test confirmed: editor:toggle-numbered-list, editor:toggle-bullet-list, editor:toggle-bold, and editor:indent-list all affected only 1 line regardless of invocation method. Fixed by wrapping app.commands.executeCommand via around() to temporarily expand the CM6 selection to the full linewise range from vim.sel before any Obsidian command executes, then restoring cursor-only after. Covers all invocation paths: hotkeys, command palette, toolbar, and programmatic executeCommandById. (#41)
    • Plugin: src/vim/visual-line-command-fix.ts — installVisualLineCommandFix() wraps app.commands.executeCommand using the existing around() utility (safe for multi-plugin stacking); installed in onload(), cleaned up in onunload()
    • Spike test: test/specs/spikes/spike23-visual-line-hotkey-commands.e2e.ts — 10 tests verifying direct command, hotkey, and selection state behavior

[0.32.0] - 2026-07-05

Added

  • Select mode (gh/gH/g<C-h>) — Vim select mode where typing replaces the selection and enters insert mode. gh enters charwise, gH linewise, g<C-h> blockwise. <C-g> toggles between visual and select mode. <BS> deletes the selection. Matches Neovim behavior. (#45)
    • Fork: enterSelectMode, toggleSelectMode, preventReselect actions in vim.js; selectMode flag on vim state; 'select' context for keymap dispatch with visual fallback; gv preserves and restores select mode via lastSelection
    • Fork: :smap, :snoremap, :sunmap, :smapclear ex commands for select-mode-specific mappings
    • Fork: selectmode option (set selectmode=cmd makes v/V/<C-v> enter select mode); keymodel option (accepted, shifted cursor key behavior deferred)
    • Plugin: status bar shows SELECT, data-vim-mode="select", powerline CSS with ::after triangle, Style Settings entries
    • 16 fork browser tests, 5 Neovim golden test cases, 3 e2e tests
  • Virtual Replace mode (gR) — replace mode that operates on screen columns instead of byte positions. TAB-aware virtual column math with replace stack for <BS> restore. <Insert> toggles between virtual replace and insert mode. (#45)
    • Fork: virtualReplaceChar and virtualReplaceBackspace adapter methods in cm_adapter.ts; virtualReplace flag and replaceStack on vim state; {mode: "vreplace"} mode change event
    • Plugin: status bar shows V-REPLACE, data-vim-mode="vreplace", powerline CSS, Style Settings entries
    • 10 fork browser tests, 3 Neovim golden test cases, 2 e2e tests
  • Visual Line / Visual Block mode indicators — status bar now distinguishes V-LINE and V-BLOCK from VISUAL. Uses the fork’s existing subMode event field. (#45)
    • Plugin: mode-tracker maps subMode: "linewise" → visualLine, "blockwise" → visualBlock; data-vim-mode="v-line" / "v-block"; powerline CSS + Style Settings entries
    • 3 e2e tests
  • Command-line and Search mode indicators — status bar shows COMMAND when : prompt is open and SEARCH when / or ? prompt is open. Detects dialog type via DOM text node inspection of the fork’s "dialog" event. (#45)
    • Plugin: dialogHandler in mode-tracker with preDialogMode tracking for restoration on dialog close; getDialogPrefix() walks DOM child nodes; data-vim-mode="command" / "search"; powerline CSS + Style Settings entries
    • 5 e2e tests (including rapid : → Esc → / → Esc cycling)
  • Insert-Normal mode indicator — status bar shows the configured insert-normal prompt (default NORMAL) when <C-o> is pressed in insert mode, then returns to INSERT after one command. (#45)
    • Plugin: mode-tracker detects subMode.startsWith('ctrl-o') → insertNormal; data-vim-mode="insert-normal"; powerline CSS
    • 2 e2e tests
  • All 11 mode prompts configurable — mode prompt text for all modes (normal, insert, visual, v-line, v-block, replace, select, v-replace, command, search, insert-normal) is configurable via Settings UI and vimrc (let g:mode_prompt_visual_line = "VL", etc.)
    • Plugin: ModePrompts interface expanded; settings UI entries for all modes; vimrc VIMRC_MODE_MAP with snake_case → camelCase mapping; RELOAD_KEYS updated
  • Configurable which-key popup delay — the delay before the which-key popup appears is now configurable via Settings → Vim Motions → Which-key hints → Which-key popup delay or set whichkeydelay=<ms> (alias wkd) in vimrc. Range 0–2000ms, default 500ms. Set to 0 for instant display. Once the popup is visible, subsequent keystrokes update it instantly — the delay only applies to the initial appearance. Single-key commands that resolve immediately never trigger the popup regardless of delay setting.
    • src/settings.ts: added whichKeyDelay: number to VimMotionsSettings (default 500), added to RELOAD_KEYS, added number input control in “Which-key hints” group
    • src/vimrc/loader.ts: added whichkeydelay / wkd to KNOWN_SET_OPTIONS (number, 0–2000)
    • src/ui/which-key.ts: replaced hardcoded SHOW_DELAY with configurable showDelay constructor parameter; onKeyPressGeneral updates overlay immediately when already visible instead of restarting delay; extracted showCompletionsIfPartial() helper
    • src/ui/global-which-key.ts: same pattern — configurable delay, instant updates when overlay already visible
    • src/main.ts: passes settings.whichKeyDelay to both WhichKeyOverlay and GlobalWhichKeyOverlay constructors

Fixed

  • <C-o> in replace mode returns to insert instead of replace — oneNormalCommand now saves the pre-Ctrl-O mode state and returns to the correct mode (insert, replace, or virtual replace) after the single normal command. Uses _suppressModeSignal to prevent a spurious {mode:"normal"} event, emitting {mode:"normal", subMode:"ctrl-o"|"ctrl-o-replace"|"ctrl-o-vreplace"} instead. (#45)
    • Fork: insertModeReturnArgs on vim state; _suppressModeSignal flag in exitInsertMode
    • 5 fork browser tests, 2 Neovim golden test cases, 2 e2e tests
  • R mode <BS> does not restore original character — regular replace mode now maintains a replace stack (same mechanism as virtual replace). <BS> restores the original character under cursor, matching Neovim behavior. Previously, <BS> only moved the cursor left. (#45)
    • Fork: handleReplaceModeInput pushes original chars to replaceStack before overwriting; BS pops and restores with explicit setCursor for correct positioning
    • 4 fork browser tests
  • Replace/vreplace character I/O only works through DOM events — unified replace mode character handling from index.ts (DOM-only path) into vim.js (handleReplaceModeInput). Vim.handleKey is now authoritative for all replace-mode operations — programmatic dispatch, macro replay, and dot-repeat work correctly through both paths. (#45)
    • Fork: handleReplaceModeInput in vim.js called from handleKeyInsertMode match.type == 'none' branch; virtualReplaceChar/virtualReplaceBackspace adapter methods; removed overwrite block and helpers from index.ts
    • 7 fork browser tests (overwrite, BS restore, dot-repeat, macro replay, Ctrl-H)

Documentation

  • docs/configuration/status-bar.md: lists all 11 mode indicators, all data-vim-mode attribute values, all CSS variables, all vimrc directives, fork mode requirement callout
  • docs/configuration/settings.md: all 11 mode prompt settings with vimrc equivalents
  • docs/guides/style-settings.md: all 20 powerline CSS variables (bg + fg for 10 modes)
  • docs/reference/keybindings.md: select mode (gh, gH, g<C-h>, <C-g>, gV) and virtual replace (gR) sections
  • docs/reference/known-limitations.md: select mode and virtual replace mode limitations
  • KNOWN_LIMITATIONS.md: selectmode=mouse CM6 limitation, selectmode=key/keymodel=startsel deferred, East Asian Width, gR newline behavior
  • DIFFERENCES.md (fork): 7 new sections covering select mode, virtual replace, replace stack, unified char handling, Ctrl-O fix, mapping commands, type changes

[0.31.0] - 2026-07-04

Fixed

  • Obsidian commands (Tab/indent, formatting toggles) only affect cursor line in visual-line mode — when vim didn’t handle a key in visual-line mode, the event propagated to Obsidian with a cursor-only CM6 selection. Obsidian’s commands (editor:indent-list, editor:toggle-bold, etc.) only saw one line instead of the full visual selection. Fixed by temporarily expanding the CM6 selection to the full linewise range before the event propagates, then restoring cursor-only via microtask after Obsidian’s command executes. (#41)
    • Fork: handleKey in index.ts now expands CM6 selection on unhandled keys during visual-line mode and restores cursor-only via Promise.resolve().then()

[0.30.0] - 2026-07-04

Added

  • User-configurable global key mappings (gmap/gnoremap/gunmap) — non-editor key bindings (graph view, canvas, PDF, reading mode, file explorer, empty workspace) can now be customized via .obsidian.vimrc. Previously, all non-editor bindings were hardcoded. The <leader> key is shared with editor mappings. (#43)
    • gmap <leader>f :obcommand switcher:open — bind <leader>f to open the quick switcher in non-editor views
    • gnoremap <leader>s :sidebar left — functionally identical to gmap (accepted for vim syntax familiarity)
    • gunmap H — remove the default H → previous tab binding (key propagates to Obsidian)
    • Right-hand side supports :obcommand <id> for Obsidian commands and :<ex-command> [args] for global ex commands
    • User bindings override defaults; gunmap removes any binding (user or default)
    • Count prefix support: 5j scrolls 5 lines, 3gt goes to tab 3 (matching existing behavior)
    • New files: src/workspace/global-mapping-registry.ts (registry with prefix-matching resolver), src/workspace/global-defaults.ts (default binding table)
    • Refactored src/workspace/global-key-handler.ts from 770-line hardcoded state machine to 255-line table-driven dispatch via GlobalMappingRegistry
    • E2E tests: test/specs/gmap.e2e.ts (12 tests), test/specs/gmap-vimrc.e2e.ts (9 tests)
  • Global which-key overlay — non-editor key sequences now show a which-key popup after 500ms, displaying available completions. Pressing <C-w> shows h/j/k/l/v/s/c/q/o window commands. Controlled by the existing whichKeyMode setting (off/leader/all).
    • New file: src/ui/global-which-key.ts — GlobalWhichKeyOverlay class, shares CSS with editor which-key
    • Reuses vim-motions-which-key CSS classes from styles.css (no CSS changes needed)
    • Popout window support via Document parameter tracking
    • Dismiss on sequence completion, timeout, or focus change to editor
  • Global which-key labels (gwhichkeylabel/gwhichkeygroup) — label global bindings for the non-editor which-key overlay, independent from editor which-key labels
    • gwhichkeylabel <leader>f Open file — shows “Open file” instead of the raw command ID
    • gwhichkeygroup <leader> +leader — groups <leader>* bindings under a named prefix
  • :gmap ex command — lists all active global bindings with source (default/user) in a modal. Available in both editor and non-editor : command contexts.
  • executeGlobalExCommand helper — exported from global-ex-command.ts for programmatic ex command dispatch without opening the modal UI

Documentation

  • docs/configuration/vimrc.md: added gmap/gnoremap/gunmap/gwhichkeylabel/gwhichkeygroup to supported commands table, added “Global key mappings” section with full syntax and examples
  • docs/features/workspace-navigation.md: added “Customizing global bindings” section
  • docs/configuration/which-key.md: updated modes to note non-editor overlay support, added “Global (non-editor) labels” section
  • docs/reference/keybindings.md: added :gmap ex command, added customization note to non-editor bindings section

[0.29.0] - 2026-07-03

Fixed

  • Visual-line cursor lands inside widget decorations in Live Preview — when entering visual-line mode (V) at a non-zero column (e.g., cursor on a in - a) and moving down to a line with a checkbox (- [ ] d), the cursor position sel.head.ch was preserved from the starting column. In Live Preview, [ ] is replaced by a checkbox widget via Decoration.replace; placing the cursor inside this replaced range caused the visual-line highlight to disappear. Fixed by always using column 0 for the cursor-only CM6 selection in visual-line mode, matching Neovim’s behavior. (#41)
    • Fork: updateCmSelection in vim.js now uses cm.setCursor(sel.head.line, 0) instead of cm.setCursor(sel.head.line, sel.head.ch)
    • 2 new Neovim golden comparison test cases: V from mid-column + j + d with checkbox content, V from mid-column + 2j + y cursor at col 0

Documentation

  • Added Quartz-powered documentation site at saberzero1.github.io/motions with full feature reference, getting started guide, and changelog.

[0.28.0] - 2026-07-03

Fixed

  • Async motion callback exits visual-line mode — when an EasyMotion async motion resolved in visual-line mode, the .then() callback called updateCmSelection(cm) outside of a cm.operation() context. With cursor-only CM6 selection, handleExternalSelection detected visualMode && !somethingSelected() and exited visual mode. Fixed by wrapping the callback’s updateCmSelection call in cm.operation() with isVimOp = true, matching the protection used by all other vim operation entry points. (#41)
    • Fork: async motion visual mode branch in vim.js now wraps updateCmSelection in cm.operation() with isVimOp = true
    • Test: easymotion-visual.e2e.ts updated to verify visual-line easymotion via register content (yank + getRegisterContent) instead of getSelection(), which returns empty with cursor-only CM6 selection
  • Visual line selection overlap in Live Preview — visual-line mode (V) rendered both the plugin’s custom full-line highlight decoration and the native CM6 ::selection CSS simultaneously, causing a visible double-highlight. Fixed by adding a .cm-vimVisualLine class to the editor scrollDOM when in visual-line mode and extending the ::selection transparency rule to suppress native selection rendering in that mode. Charwise (v) and blockwise (Ctrl-V) visual modes are unaffected. (#41)
  • Visual-line cursor displacement over collapsed markup in Live Preview — navigating with V + j/k on lines containing collapsed markup ([[wikilinks]], [text](url)) caused Obsidian to uncollapse the hidden content, reflowing the line and making the cursor appear to need extra steps. Root cause: updateCmSelection set a spanning CM6 EditorSelection range across the full line content; Obsidian’s Live Preview detects selection overlap with Decoration.replace ranges and reveals them. Fixed by setting a cursor-only CM6 selection (at sel.head position) in visual-line mode — the linewiseVisualHighlight ViewPlugin already provides the visual highlight independently from vim.sel, and operators (y/d/c) recompute their own selection at dispatch time. (#41)
    • Fork: updateCmSelection in vim.js now sets cm.setCursor(sel.head) instead of a spanning range when vim.visualLine is true
    • Fork: joinLines action in vim.js now reads from vim.sel instead of cm.getCursor('anchor'/'head') in visual mode, fixing V + J regression from cursor-only selection
    • Fork: replace action in vim.js now reads from vim.sel instead of cm.getCursor('start'/'end') in visual mode, with line boundary expansion for visual-line; removed unused selections variable
    • Fork: index.ts adds Ctrl+C special-case that copies linewise text from vim.sel when somethingSelected() returns false in visual-line mode
    • Fork: index.ts adds .cm-vimVisualLine class toggle in updateClass()
    • Fork: block-cursor.ts extends ::selection suppression CSS selector to include .cm-vimVisualLine
    • Plugin: styles.css overrides .cm-vim-linewise-selection with var(--text-selection) for theme alignment (already present from 0.27.0)
    • 6 new Neovim golden comparison test cases: V+j+y cursor position, V+2j+d multi-line delete, Vk upward selection, Vjk round-trip, v→V transition, V→v transition
    • 7 new e2e tests: visual-line yank with markup content, multi-line yank register verification, gv after visual-line yank, v→V and V→v mode transitions

[0.27.1] - 2026-07-03

Fixed

  • Custom vimrc path setting missing from Obsidian 1.13+ settings — the “Custom vimrc path” text input was present in the legacy display() rendering but missing from the getSettingDefinitions() declarative API. On Obsidian 1.13+, users could not see or configure the custom vimrc path in settings. Added the vimrcPath text control to the “Vimrc & key bindings” group in getSettingDefinitions(), with aliases for settings search discoverability and a disabled predicate gated on enableVimrc. (#34)

[0.27.0] - 2026-07-03

Added

  • Vimium-style hint actions in non-editor views — hint mode now supports multiple actions via a key-tree dispatch when a non-editor view (graph, PDF, canvas, etc.) is focused. f activates (click/focus), F opens in a new pane, yf yanks the target’s URL or text to clipboard, df closes the target tab or pane. Count prefix works: 3f activates three targets sequentially. In editor context, <leader><leader>h (unchanged) triggers hints with Ctrl/Cmd modifier during label selection upgrading to open-in-new-pane.
    • src/ui/hint-mode.ts: refactored into action-dispatch architecture with HintTarget type classification (link/pane/tab/button/input/generic), four action functions (hintActivate/hintOpenNew/hintYank/hintClose), createHintActions() factory, count support via requestAnimationFrame recursion, modifier-based action upgrade, el.isConnected validation, clipboard fallback
    • src/workspace/global-key-handler.ts: added Y_PENDING/D_PENDING states to SeqState enum, hintActions constructor parameter, f/F/y/d dispatch in IDLE and COUNT states, handleYPending/handleDPending handlers, chordText() updates
    • src/main.ts: registerHintMode() → registerHintActions(), hintModeAction → hintActions field, stale hotkey closure fix (indirection pattern), reloadFeatures() reset, three new Obsidian commands
    • New Obsidian commands: vim-motions:hint-open-new-pane, vim-motions:hint-yank, vim-motions:hint-close
    • E2E tests: 10 new tests covering non-editor f/F/yf/df, modifier upgrade, escape, invalid sequence reset, command registration

Fixed

  • Global key handler intercepts navigation keys in Obsidian settings modal — j/k/g/z/: and other navigation keys were consumed by GlobalKeyHandler when the settings modal was open. Navigation keys are now suppressed when .modal-container is detected in the DOM via isModalOpen(). Hint actions (f/F/yf/df) still work in modals — they use a separate shouldInterceptHints() gate that does not check for modals.
  • Hint mode labels re-trigger instead of selecting label characters — pressing f to activate hint mode, then typing a label character that is also f, would re-trigger hint mode via GlobalKeyHandler instead of being captured by the label selection handler. Fixed by adding an isHintModeActive() flag (exported from hint-mode.ts) that makes GlobalKeyHandler bail entirely during label selection.
  • Settings toggles not responding to hint activation — Obsidian’s toggle controls (.checkbox-container, a <label> element) required pointerdown/pointerup events before click to trigger the toggle handler. Added full pointer event sequence dispatch for generic element activation.
  • Settings dropdowns cycling to wrong element on Obsidian 1.13+ — Obsidian 1.13+ adds hidden <select class="dropdown is-measuring"> shadow copies of every dropdown for layout measurement. These shadow selects have only 1 option and are positioned at the same coordinates as the real dropdown, causing hint labels to sometimes target the measurement copy. Fixed by filtering out elements with the is-measuring class during target discovery.
  • Settings controls require Escape before re-activating hints — after activating a toggle or cycling a dropdown in the settings modal, focus remained on the control element, preventing GlobalKeyHandler from intercepting f for the next hint activation. Fixed by blurring the activated element (and any focused child) after activation when inside a .modal-container.
  • Dropdowns only focus but don’t change value — <select> elements cannot be programmatically opened in Chromium. Changed activation behavior to cycle to the next option value and dispatch a change event, giving immediate feedback instead of requiring manual Arrow key interaction.
  • Broadened form control selectors — STANDARD_SELECTORS now includes input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), and select:not([disabled]) to ensure all visible form controls (text inputs, search bars, dropdowns) receive hint labels regardless of their Obsidian-specific parent structure. Removed redundant Obsidian-specific selectors that were subsets of the broader standard selectors. Changed .setting-item-control .checkbox-container to .checkbox-container to match toggles rendered by Obsidian 1.13+‘s declarative settings API outside the traditional .setting-item-control parent.

Changed

  • Scrolloff cap raised from 20 to 9999 — the scrolloff setting now accepts values up to 9999 (previously capped at 20), enabling the standard Vim pattern of set scrolloff=999 to keep the cursor vertically centered while scrolling. The Settings UI control has been changed from a slider to a validated number input field. Affects all four validation points: Settings UI (structured + manual rendering), vimrc set scrolloff=N / set so=N, and the vim defineOption callback. The underlying CSS scrollMargins implementation was already uncapped. (#40)
    • src/settings.ts: structured definition changed from type: 'slider' to type: 'number' with max: 9999; manual rendering changed from .addSlider() to .addText() with type='number', min='0', max='9999', integer clamping, and fallback to default 5 on invalid input
    • src/vimrc/loader.ts: scrolloff and so option definitions updated from max: 20 to max: 9999
    • src/vim/options.ts: defineOption callback validation updated from n <= 20 to n <= 9999

Documentation

  • KNOWN_LIMITATIONS.md: added “Hint mode actions” section documenting the vimium-style key-tree, context split, modifier upgrade, target classification, settings gating, modal behavior, clipboard fallback, and stale target handling
  • KNOWN_LIMITATIONS.md: updated “Global workspace navigation” supported keys to include hint actions (f/F/yf/df)
  • KNOWN_LIMITATIONS.md: updated “Scrolloff line height assumption” section to document the raised cap and centered-cursor pattern
  • README.md: updated hint mode section with vimium-style actions, non-editor key table, and new Obsidian commands
  • README.md: updated workspace keyboard control table with hint action keys
  • README.md: updated scrolloff range from 0–20 to 0–9999 in number options table and settings list; updated scrolloff description to mention set scrolloff=999 for centered cursor

[0.26.0] - 2026-07-02

Fixed

  • Stale jumpList markers crash vim state on document switch — gg, G, and other motions with toJumplist: true threw RangeError: Invalid position N in document of length M when switching between documents of different lengths (especially with PDF++ plugin). The global jumpList stored Marker objects with absolute offsets from the previous (longer) document. When jumpList.add() called curMark.find() on a stale marker, posFromIndex passed the old offset to doc.lineAt() without bounds checking, crashing through processMotion → processCommand → the cm.operation() try-catch, which wiped and re-initialized vim state. Subsequent keystrokes fell through to default CM6 text insertion. (#18)
    • Fork: posFromIndex now clamps offset to [0, doc.length], mirroring indexFromPos bounds checking
    • Fork: Marker.find() catches exceptions and returns null for stale markers (all callers already handle null)
    • Fork: Marker.update() catches RangeError from mapPos() when marker offset exceeds the changeset’s starting document length, setting offset = null
    • Plugin: reloadFeatures() now calls vim.resetKeymap() to match onload() behavior, closing a defense gap where 33 settings-triggered reloads could corrupt the keymap without recovery
    • 5 new fork tests (posFromIndex clamping, negative offset, valid offset, marker doc-shrink, gg/G with stale jumpList)
    • 3 new plugin e2e tests (gg after doc switch, G after doc switch, gg/G after reloadFeatures on shorter doc)
  • Visual line mode (V) highlight doesn’t match Obsidian theme — the linewise selection highlight used hardcoded rgba colors via the fork’s EditorView.baseTheme, which didn’t adapt to Obsidian themes. The fork’s &light/&dark CSS variants never activated because Obsidian doesn’t add cm-dark/cm-light classes to .cm-editor. Added a CSS override in styles.css using var(--text-selection) (Obsidian’s accent-derived selection color) at specificity 0-3-0, which beats both the fork’s base theme (0-2-0) and Obsidian’s code block background (0-2-1) without !important. (#38)
  • Visual line mode highlight invisible inside code blocks — the linewise selection Decoration.line() class competed with Obsidian’s HyperMD-codeblock-bg class on the same .cm-line element. The code block background (applied at specificity 0-2-1 via .cm-s-obsidian div.HyperMD-codeblock-bg) won the specificity fight. Fixed by the same CSS override above — specificity 0-3-0 beats 0-2-1. (#38)
  • Visual block select (Ctrl-V) on EOL displaces cursor rightward — makeCmSelection in the fork’s block mode branch added +1 to toCh for inclusive selection without per-line clamping. When $ (end-of-line) set toCh to the actual line length, toCh + 1 pushed the cursor one position past the last character. Fixed by clamping toCh and fromCh to each line’s length inside the per-line loop, since each line in a block selection has a different length. The $ motion’s Infinity return for ch is preserved upstream — clamping only happens at the selection-building stage. (#38)
  • Formatting mark transaction filter corrupts visual selections — the EditorState.transactionFilter in formatting-mark-fix.ts snapped cursor positions past formatting marks (**, *, `, ~~, ==) for all selection changes, including visual mode selections. When extending a visual selection across formatted text in Live Preview, the filter’s snapRange function modified the selection head to a formatting mark boundary, causing the selection to jump or collapse unexpectedly. Fixed by adding a range.empty guard that skips snapping for non-empty (visual) selections — the formatting mark correction is only needed for normal-mode cursor movement. (#38)
    • Fork: makeCmSelection block mode now clamps toCh/fromCh per-line via lineLength(cm, top + i)
    • Plugin: formatting-mark-fix.ts skips snapRange when range.empty is false
    • Plugin: styles.css adds .cm-editor .cm-line.cm-vim-linewise-selection override with var(--text-selection) fallback chain
  • Visual block $ delete cursor deviation — <C-v>jj$d leaves cursor at ch:1 instead of Neovim’s ch:0 after deleting to EOL. This is a pre-existing cursor-after-block-delete positioning issue in the fork (content is correct, only cursor position differs). Registered as a known deviation in test/neovim/deviations.ts.

Documentation

  • KNOWN_LIMITATIONS.md: updated “Formatting mark cursor correction in Live Preview” section to document the visual mode bypass
  • KNOWN_LIMITATIONS.md: updated “Block visual mode” section test coverage count (13 → 15 golden tests)
  • DIFFERENCES.md (fork): added “Block visual EOL cursor clamping” section documenting makeCmSelection per-line clamp
  • README.md: updated recommended setup to mention theme-aligned visual line highlighting

[0.25.0] - 2026-07-02

Fixed

  • Vim engine settings changed via Settings UI not taking effect — changing clipboard, tabstop, shiftwidth, expandtab, insertmodeescape, insertmodeescapetimeout, or textwidth in Settings → Vim Motions → Vim engine only persisted the value to disk but did not push it to the vim engine via vim.setOption(). The setting appeared to save but had no effect until Obsidian was reloaded. The same settings worked correctly when set via .obsidian.vimrc because the vimrc loader explicitly calls vim.setOption(). Fixed by adding vim.setOption() calls to each vim engine setting’s onChange handler in src/settings.ts. For clipboard and textwidth, the module-level state helpers (setClipboardOption, setTextwidth) are also called to match the vimrc loader’s behavior. (#39)

Added

  • Style Settings integration — powerline status bar colors and jump label colors are now customizable via the Style Settings plugin. The styles.css file includes a /* @settings */ block exposing 12 color pickers with separate light/dark mode defaults: powerline background and text for each vim mode (normal, insert, visual, replace), EasyMotion label background/text, and hint mode label background/text. The plugin triggers parse-style-settings on load/unload so Style Settings discovers the configuration automatically. Users without Style Settings are unaffected — the existing CSS variable fallback chain (--vim-pl-*-bg → Obsidian theme variable → hardcoded fallback) continues to work identically. (#37)
  • Global workspace navigation — workspace keyboard commands (<C-w>h/j/k/l, gt/gT, H/L, :q, scroll keys, etc.) now work across ALL Obsidian views, not just markdown editors. When a non-editor view (PDF, graph, canvas, image, backlinks, etc.) is focused, a capture-phase keydown handler intercepts workspace-relevant keystrokes and dispatches them via Obsidian’s command system. When a CodeMirror editor is focused, codemirror-vim handles everything as before — no regression. (#35)
    • Navigation: <C-w>h/j/k/l (focus pane), <C-w>v/s (split), <C-w>c/q (close), <C-w>o (close others), gt/gT (next/prev tab), Ngt (Nth tab), H/L (prev/next tab), Ctrl-o/Ctrl-i (history back/forward)
    • Scrolling: j/k (line scroll), gg/G (top/bottom), Ctrl-d/u (half page), Ctrl-f/b (full page), with count prefix support (5j = 5 lines)
    • Ex command line: : opens a standalone command modal with tab-completion for 34 globally-safe ex commands (:q, :wq, :e {file}, :sp, :vs, :ob {cmd}, etc.)
    • Chord display: pending keystrokes (<C-w>, g, 3) shown in status bar via setGlobalChord() on VimModeTracker
    • Sequence timeout: multi-key sequences reset after 1000ms (matches vim’s timeoutlen)
    • Popout window support: handler installed on all windows via workspace.on('window-open')
    • Input suppression: keys not intercepted in text inputs, contentEditable, modals, command palette, or IME composition
    • Scroll target detection: DOM tree-walking finds the largest scrollable container in arbitrary views (same approach as obsidian-vim-keynav)
    • New file: src/workspace/global-key-handler.ts — GlobalKeyHandler class with shouldIntercept(), SequenceStateMachine, scroll target detection
    • New file: src/ui/global-ex-command.ts — GlobalExCommandModal extending Obsidian’s SuggestModal
    • src/vim/mode-tracker.ts: added setGlobalChord(text) method for non-editor chord display
    • src/workspace/navigation.ts: exported executeCommand() for reuse by global handler
    • E2E test suite test/specs/global-nav.e2e.ts with 15 tests covering navigation, scrolling, ex commands, input suppression, sequence timeout, and no-regression
  • H/L tab switching in non-editor views — repurposes H/L (screen top/bottom in editors) for previous/next tab navigation when a non-editor view is focused, matching obsidian-vim-keynav conventions
  • Ctrl-o/Ctrl-i history navigation in non-editor views — maps to app:go-back / app:go-forward when no editor is focused (in editor context, codemirror-vim uses these for the within-file jumplist)
  • Custom vimrc file path — new setting to load vimrc from a custom vault path instead of the default .obsidian.vimrc. Useful when using Obsidian Sync, which skips dotfiles. The setting provides file-suggest autocompletion filtered to *.vimrc files in the vault. Leave empty to use the default .obsidian.vimrc. Changing the path triggers a full vimrc reload. (#34)
    • src/settings.ts: added vimrcPath: string to VimMotionsSettings interface and defaults, added vimrcPath to RELOAD_KEYS, added file-suggest text input below the “Load .obsidian.vimrc” toggle
    • src/ui/vimrc-file-suggest.ts: new file — VimrcFileSuggest extends Obsidian’s AbstractInputSuggest<TFile> to autocomplete vault files ending in .vimrc
    • src/vimrc/loader.ts: getVimrcPath(), loadVimrc(), and resolveLeaderKey() accept optional customPath parameter
    • src/main.ts: passes settings.vimrcPath to loader functions
    • E2E test suite test/specs/vimrc-custom-path.e2e.ts with 7 tests covering custom path loading, default fallback, non-existent path resilience, and non-dotfile path for Sync compatibility

Changed

  • <C-w>o, :only, :qa, :xall now close ALL view types — previously filtered by getViewType() === 'markdown', leaving PDFs/images/etc. open. Now closes all tabs regardless of view type, matching Neovim behavior. Same change applied to g<C-t> (goto Nth tab) which now counts all leaves, not just markdown.

Documentation

  • KNOWN_LIMITATIONS.md: added “Global workspace navigation” section documenting Ctrl-d/f/b Obsidian hotkey prerequisite and scroll target limitations; updated “Vimrc hot-reload” section to note that vim engine settings now hot-reload via Settings UI
  • KNOWN_LIMITATIONS.md: updated “Vimrc hot-reload” section to document custom vimrc path behavior
  • README.md: updated workspace keyboard control section with global navigation commands, scrolling keys, and standalone ex command line; added hotkey unbinding note for Ctrl-d/f/b; updated Vim engine settings section to note immediate hot-reload
  • README.md: updated powerline status bar description to mention Style Settings support; updated label colors description to mention Style Settings
  • README.md: updated vimrc support section, settings list, and quality of life to document custom vimrc path setting
  • styles.css: added /* @settings */ block with Style Settings variable bindings; powerline CSS variables moved from local definitions to inline fallbacks for Style Settings compatibility

[0.24.0] - 2026-07-01

Changed

  • Formatting mark cursor fix rewritten — replaced RangeSetBuilder.prototype monkey-patching with a CM6 EditorState.transactionFilter that corrects cursor positioning near formatting marks in Live Preview. The new approach walks the Lezer syntax tree to identify formatting mark nodes and snaps cursor endpoints that land inside mark ranges to the nearest boundary. Includes end-of-line boundary handling to prevent cursor oscillation when formatting marks extend to the line end (e.g. **he** with no trailing content). This eliminates conflicts with obsidian-latex-suite (#32) and fixes formatting marks being visible in live preview (#33). The 'always' formatting mark mode has been removed (users are migrated to 'cursor').

Added

  • Block visual insert (I/A), change (c/C) — CTRL-V block visual mode now supports I (insert at left column), A (append at right column), c (change block), and C (change to EOL) with multi-cursor editing on all selected lines. Text appears on all lines in real-time as you type (unlike Neovim, where text only appears on the primary cursor until <Esc>). Short lines that don’t reach the block column are skipped, matching Neovim behavior. Dot-repeat (.) works for block insert operations. Block visual delete (d), yank (y), paste (p/P), indent (>/<), replace (r), and case toggle (~) were already working.
    • Fork: enterInsertMode preserves wasInVisualBlock before exitVisualMode clears the flag
    • Fork: selectForInsert skips lines shorter than the block column instead of clipping
    • Fork: operators.change adds a vim.visualBlock path for block change and block change-to-EOL
    • Fork: exitInsertMode positions cursor at the block’s left column via blockInsertLeft instead of the standard ch - 1, matching Neovim’s cursor placement after block A
    • Fork: makeCmSelection block mode treats fromCh === toCh (zero-width block) the same as fromCh < toCh, fixing C on zero-width blocks
    • Fork: repeatInsertModeChanges uses blockInsertLeft for cursor placement after dot-repeat instead of hardcoded +1
  • Neovim golden comparison tests for block visual: 13 golden test cases in test/specs/vim-builtin/visual-block-golden.e2e.ts covering insert, append, change, change-to-EOL, delete, case toggle, replace, short-line handling, block yank/paste, zero-width block C, zero-width block I, A cursor position, and upward selection
  • Spike test suite test/specs/spikes/spike-block-insert.e2e.ts with 10 tests covering all block visual insert scenarios
  • Command index entries: CTRL-V_I, CTRL-V_A, CTRL-V_c, CTRL-V_C, q, @, @@
  • Neovim golden comparison tests for marks: 5 golden test cases in test/specs/vim-builtin/marks-golden.e2e.ts covering ma/'a, `b, '., '', ``
  • Neovim golden comparison tests for macros: 5 golden test cases in test/specs/vim-builtin/macros-golden.e2e.ts covering qa/@a, 2@a, @@, 3@a, insert replay
  • Expanded register golden tests: 3 new cases ("Ayy append, "0p numbered register, "a/"b independent) in normal-yank-put suite
  • Expanded search/replace golden tests: 2 new cases (:%s global, :2,3s range) in ex-commands-builtin suite
  • Formatting mark cursor golden tests: 3 new cases (w through **, f past **, e through backticks) in normal-motions suite

Fixed

  • Vimrc whichkeygroup/whichkeylabel commands crash on load — defineEx('whichkeygroup', 'wkg', ...) threw Error: (Vim.defineEx) "wkg" is not a prefix of "whichkeygroup" because defineEx requires the short form to be an actual starting substring of the command name, not an arbitrary abbreviation. Same issue for whichkeylabel/wkl. Fixed by changing the prefixes to valid substrings: whichkeyg and whichkeyl. User-facing vimrc syntax (whichkeygroup, whichkeylabel) is unchanged. The set whichkeygrouping/set wkg option alias (handled by a separate KNOWN_SET_OPTIONS path) was already correct and unaffected. (#31)
  • Block visual mode deviations removed — all CTRL-V block visual deviations in test/neovim/deviations.ts have been removed. Block insert/change now matches Neovim output with zero deviations: cursor position after A exit is correct, short lines are skipped, and zero-width blocks work for all operators.
  • Golden recording infrastructure — test/neovim/record-golden.ts now sends <Esc><Esc> before each test case to reset Neovim to normal mode, preventing stale visual/insert mode state from leaking between test cases. This fixed 5 pre-existing incorrect golden values in g-commands.json (3 mode corrections) and visual-mode.json (1 mode correction, 1 cursor + mode correction).
  • Search dispatch in test wrapper — test/neovim/test-wrapper.ts now detects /pattern\n and ?pattern\n search sequences and dispatches the search + post-keys separately with a settle pause, improving reliability for search-dependent golden tests.

Documentation

  • KNOWN_LIMITATIONS.md: added “Block visual mode (CTRL-V) insert not supported (Fixed)” section
  • DIFFERENCES.md (fork): added “Block visual insert (I/A), change (c/C)” section documenting all 6 fork changes
  • README.md: added block visual insert/change to recommended setup section

[0.23.0] - 2026-07-01

Added

  • Declarative settings API (getSettingDefinitions) — implemented Obsidian’s 1.13.0+ declarative settings API with a version guard. On Obsidian 1.13.0+, plugin settings appear in Obsidian’s global settings search and use the new declarative rendering pipeline. On older versions, the existing imperative display() method continues to work unchanged. No minAppVersion bump required.
    • getSettingDefinitions() returns all settings organized into groups (Vim features, Vim engine, Jump navigation, Status bar, Mode prompts, Cursor shapes, Vimrc & key bindings, Leader key bindings, Which-key hints, Which-key group/command labels, Advanced)
    • getControlValue()/setControlValue() overrides handle dot-notation keys for nested settings (modePrompts.normal, cursorShapes.insert), clear vimrc overrides on user change, and trigger reloadFeatures() for settings that require it
    • Vimrc-overridden settings are disabled via disabled: () => isOverridden(key) predicates
    • Complex sections (leader bindings, which-key group/command labels, hotkey recorder) use render callbacks delegating to the existing imperative rendering methods
    • styles.css: added .vim-motions-hidden utility class for render callback placeholder rows

Documentation

  • README.md: updated Settings section to note settings search compatibility on Obsidian 1.13.0+

[0.22.0] - 2026-06-30

Added

  • Mobile support — the plugin is no longer desktop-only. Changed isDesktopOnly to false in manifest.json. EasyMotion and hint mode are disabled on mobile via Platform.isMobile guards because they depend on activeDocument/activeWindow (desktop-only Obsidian globals). All other features (core vim, text objects, navigation, workspace commands, vimrc, status bar, tables, surround) work on mobile. (#30)
    • src/main.ts: added Platform.isMobile guards to skip EasyMotion and hint mode registration on mobile (in onload, reloadFeatures, and reregisterLeaderFeatures)
    • eslint.config.mts: added @codemirror/* and @lezer/* to import/no-nodejs-modules allow list — eslint-plugin-obsidianmd enables this rule when isDesktopOnly: false

Fixed

  • EasyMotion big-WORD regex crashes on iOS < 16.4 — BIG_WORD_START_RE used a lookbehind assertion ((?<=\s|^)\S) which is not supported on iOS versions before 16.4. Rewritten as a two-pass scanner: first checks start-of-line for non-whitespace, then finds \s\S transitions mid-line. The obsidianmd/regex-lookbehind lint rule (enabled when isDesktopOnly: false) caught this. (#30)
  • import/no-nodejs-modules false positives on @codemirror/* imports — eslint-plugin-obsidianmd enables this rule when isDesktopOnly: false in manifest.json. The existing import/core-modules setting does not affect this rule’s allow list. Added explicit allow entries for all @codemirror/* and @lezer/* packages to the rule configuration.
  • Configurable insert mode escape timeout — set insertmodeescapetimeout=N (alias imet, range 100–5000ms, default: 1000ms) controls how long the plugin waits between keystrokes when matching the insertmodeescape sequence (e.g. jk). Matches Neovim’s timeoutlen default of 1000ms. Previously hardcoded at 200ms — too tight for normal typing. Configurable via vimrc, Settings UI (Settings → Vim Motions → Vim engine → Insert mode escape timeout), or runtime Vim.setOption('insertmodeescapetimeout', 500). (#31)
  • Vimrc ↔ Settings parity — all plugin settings are now configurable via .obsidian.vimrc in addition to the Settings UI. When vimrc is enabled (the default), vimrc values override the corresponding Settings UI values. Settings overridden by vimrc are shown as disabled controls in the settings tab with a note indicating the vimrc directive that set them (e.g., “Set by vimrc: set scrolloff=10”).
    • Boolean feature toggles via set/set no: textobjects, navigation, hardwrap, listcontinuation, tablenav, workspacenav, easymotion, easymotiondimming, hintmode, statusbar, chorddisplay, powerline
    • Number options via set <option>=<value>: scrolloff (0–9999), scanlimit (5–200), labelfontsize (10–20)
    • String options: easymotionlabels, hintlabels
    • Enum options: tablewidget (off/cursor/always), whichkey (off/leader/all), whichkeygrouping (flat/grouped)
    • Mode prompt customization via let g:mode_prompt_normal = "N" (and insert/visual/replace)
    • Which-key group labels via whichkeygroup <leader>t Table — name key prefix groups in the which-key popup
    • Which-key command labels via whichkeylabel <leader>w Save file — describe individual bindings in the which-key popup
    • Reverse-direction settings — clipboard, tabstop, shiftwidth, expandtab, insertmodeescape, and textwidth now have Settings UI controls (previously vimrc-only)
    • Priority rule: vimrc values override Settings UI values when enableVimrc is true. Overrides are in-memory only — the on-disk settings file always reflects UI-set values. Changing an overridden setting in the UI clears the override for the current session.
    • List merge: which-key group labels and command labels from vimrc are merged with labels configured in Settings. Vimrc entries appear as read-only rows; the “Add” button remains active for user additions. Vimrc wins on conflict.
    • source directive fix: settings and guicursor in sourced vimrc files now propagate correctly (pre-existing bug where onCursorShapeChange was not passed to recursive loadVimrcFile calls)
    • vimrcLoading flag fix: the flag is now reset to false after successful vimrc load, enabling runtime :set commands to trigger immediate reloadFeatures()
  • Vimrc set command routing — all known set options are now handled directly in the vimrc loader via a KNOWN_SET_OPTIONS mapping table, calling onSettingOverride directly instead of relying on defineOption callback dispatch through vim.handleEx. This ensures reliable settings override regardless of codemirror-vim initialization order. Unknown options fall through to handleEx for forward compatibility.
  • Spurious defineOption callback prevention — registerVimOptions now uses a registered flag to prevent defineOption callbacks from firing during initial option registration (codemirror-vim calls setOption(name, defaultValue) internally during defineOption). Without this guard, every option with a truthy default would spuriously populate vimrcOverrides and trigger reloadFeatures during plugin startup.
  • E2E test suite test/specs/vimrc-settings.e2e.ts with 11 tests covering boolean/number/string/enum option overrides, mode prompts, which-key labels, override tracking, and combined overrides
  • set insertmodeescape=jk not working (frame-perfect timing required) — the InsertEscapeHandler listened to vim-keypress events, which only fire for keys processed by codemirror-vim as vim commands. In insert mode, regular character keys bypass vim entirely and go through CM6’s text input pipeline — the handler never saw them. Rewrote to use DOM keydown events on the editor element, correctly intercepting keystrokes in insert mode. Also fixed the insertmodeescape vim option not storing its value for getOption() retrieval (callback returned undefined instead of the stored value). (#31)
  • dk not deleting in operator-pending mode — dk (delete current and previous line) was a no-op because tableAwareMoveUp was registered with context: 'normal', causing it to be filtered out in operator-pending mode. CM Vim’s keymap search then failed to fall through to the default k motion. Removed the context restriction since the motion already handles operator-pending mode internally via its hasOperator check.
  • Cursor snaps to formatting mark boundary in Live Preview — placing the cursor inside formatted text (*italic*, **bold**, `code`, ~~strike~~, ==highlight==) would snap to the delimiter boundary instead of the intended position. Obsidian’s Live Preview uses Decoration.replace({}) to hide formatting marks on inactive lines, creating zero-width gaps that cause CM6’s position mapping to collapse. Originally fixed by intercepting Decoration.replace({}) via RangeSetBuilder.prototype.add patching. Later replaced with a CM6 EditorState.transactionFilter approach (see [Unreleased] section) due to conflicts with obsidian-latex-suite.
  • % bracket matching skips brackets in strings and comments — the fork’s scanForBracket fallback now calls getTokenTypeAt() for each bracket candidate and skips brackets inside "string" or "comment" tokens. Previously, positional stack counting would match a bracket inside a string literal. Note: in Markdown mode, Lezer does not classify double-quoted text as string tokens, so this primarily benefits languages with proper syntax trees.
  • <</>> indent respects shiftwidth and expandtab — the fork’s indent operator now reads the vim options shiftwidth and expandtab (via getOption()) before falling back to CM6’s tabSize and indentWithTabs. When set shiftwidth=2 or set expandtab is set in .obsidian.vimrc, the indent operator uses those values for both visual-block and line-by-line indentation.
  • V linewise visual cursor at end of line instead of column 0 — linewise visual mode (V, Vj, etc.) now positions the cursor at column 0 of the head line, matching Neovim. The fork’s makeCmSelection was setting head.ch = lineLength(line) for display, which placed the cursor at the end of the line. A ViewPlugin with Decoration.line now provides the full-line visual highlight independently of the CM6 selection head position.
  • Vimrc map re-application — vimrc key mappings are now re-applied 200ms after initial load as a safety net against CM Vim initialization timing. If the initial applyVimrcMaps call runs before the CM6 vim extension has fully settled, the delayed retry ensures mappings take effect.

Changed

  • minAppVersion bumped to 1.2.3 — required for setDisabled() API on settings controls (used to disable vimrc-overridden settings in the UI). Obsidian 1.2.3 was released March 2023.

Documentation

  • KNOWN_LIMITATIONS.md: replaced “Desktop only” section with “Mobile support” section documenting Platform.isMobile guards and feature-by-platform compatibility matrix
  • README.md: updated Requirements from “Desktop only” to “Desktop and mobile” with link to known limitations
  • KNOWN_LIMITATIONS.md: added “Insert mode escape” section documenting the keydown-based handler, configurable timeout, and the vim-keypress event limitation; updated vi* single-character status to fixed via formatting mark cursor correction; updated % + strings to note Lezer limitation in Markdown; updated << unindent entry to note fork fix; removed V linewise cursor deviation; updated nmap L $ section with investigation findings; added “Formatting mark cursor correction” section
  • README.md: added insertmodeescapetimeout to number options table and vimrc example; added insert mode escape timeout to settings list
  • DIFFERENCES.md (fork): added sections for scanForBracket string/comment awareness, indent operator shiftwidth/expandtab support, linewise visual cursor positioning with decoration-based highlight

[0.21.2] - 2026-06-29

Fixed

  • Plugin fails to load when built-in Vim mode is enabled — three fork-only API methods were called unconditionally, but do not exist on Obsidian’s built-in Vim API. When built-in Vim mode is enabled (or when another plugin pre-installs window.CodeMirrorAdapter.Vim with the built-in API), getVimApi() returns the built-in Vim object and the calls throw TypeError: … is not a function. Added typeof guards to all three call sites and marked the methods as optional in the VimApi type definition. (#29)
    • vim.resetKeymap() in onload() — prevented the plugin from loading entirely
    • vimApi.clearInputState(cm, 'pane-switch') in the active-leaf-change handler — crashed on every tab switch when a partial key buffer was pending
    • this.vim.removeMapCommand(reg.keys) in VimRegistration.removeRegistration() — crashed during plugin unload or feature toggle when cleaning up mapCommand registrations

[0.21.1] - 2026-06-29

Fixed

  • Space-as-leader key mappings not matching in codemirror-vim — Vim.map(' j', 'gj') and Vim.mapCommand(' w', ...) stored literal space in the keymap (' j'), but vimKeyFromEvent produces '<Space>' on key press. The commandMatch string comparison never found a match, so leader-prefixed sequences silently failed. The fork now normalizes literal spaces to <Space> in _mapCommand (both keys and toKeys), unmap(), and removeMapCommand(). Existing angle-bracket groups (<C-Space>, <S-Space>) are preserved. This is the root-cause fix for the space-as-leader issue — the 0.21.0 plugin-side fix (unmapDefaultBinding centralization) was necessary but not sufficient without this keymap normalization. (#21)
  • Vimrc map commands registered twice — nmap, nnoremap, and other map commands in .obsidian.vimrc were processed once correctly via deferredMaps (the plugin’s own parser) and then a second time via vim.handleEx() (codemirror-vim’s ex command parser). The handleEx path splits arguments on whitespace, so nmap <leader>j gj with space as leader became nmap j gj — a bare j → gj mapping without the leader prefix. This double-registration was masked for non-space leaders (comma, backslash) because whitespace splitting doesn’t affect those characters. Added continue after the deferredMaps.push() block, matching the pattern used by all other handled command types (let, source, set). (#21)

Documentation

  • DIFFERENCES.md (fork): added “Key string normalization for map/mapCommand” section documenting normalizeKeyString and the _mapCommand/unmap/removeMapCommand normalization points
  • KNOWN_LIMITATIONS.md: updated “EasyMotion leader key conflict” fixed section with fork-side key normalization details

[0.21.0] - 2026-06-29

Added

  • Smart list continuation on o/O — pressing o or O on a Markdown list line now automatically continues the list marker on the new line. Supports unordered lists (- , * , + ), ordered lists (1. , 1) ), task lists (- [ ] , - [x] ), ordered task lists (1. [ ] ), custom checkbox states (- [!] , - [?] , - [/] , etc.), indented lists, blockquote lists (> - ), and nested blockquotes (> > - ). Ordered lists increment the number for o (below) and keep the same number for O (above). Checked tasks always continue with an unchecked [ ] . Lines inside fenced code blocks are excluded. Controlled by Settings → Vim Motions → Smart list continuation on o/O (on by default). Disable for plain Neovim behavior.
    • Fork: added getAction(name) API to the vimApi object for action introspection, enabling the save/restore pattern for built-in action overrides
    • Plugin: added defineActionOverride method to VimRegistration that captures the original action before overriding and restores it on plugin unload — ensuring o/O revert to default vim behavior when the plugin is disabled
  • Fork test count: 1690 (up from 1686, 4 new getAction API tests)
  • E2E test suite test/specs/open-line-list.e2e.ts with 35 tests covering all list types, indentation levels, blockquotes, nested blockquotes, code block exclusion, undo, and edge cases

Fixed

  • O on first line after frontmatter behaves like o — pressing O on the first content line below YAML frontmatter inserted the new line into the frontmatter region (swallowed by Obsidian’s properties UI) instead of above the current line. Fixed in both the fork and the plugin:
    • Fork: newLineAndEnterInsertMode in vim.js compared insertAt.line === cm.firstLine() — always false when frontmatter is present. Now scans past ----delimited frontmatter to find the first editable line and uses insertAt.line <= firstEditable as the boundary check. The insertion point uses { line: insertAt.line, ch: 0 } instead of hardcoded firstLine(), so it works for all line types (plain text, headings, etc.) with or without frontmatter.
    • Plugin: the smart list continuation override in open-line.ts had the same curLine === cm.firstLine() issue. Added firstEditableLine() helper with the same frontmatter scan, changed the boundary check to curLine <= firstEditableLine(cm), and updated the insertion point to { line: curLine, ch: 0 }.
  • E2E regression tests for o/O with frontmatter: O on unordered/ordered/task list after frontmatter inserts above, o after frontmatter inserts below, O on non-list line after frontmatter inserts above, o on non-list line after frontmatter inserts below, O on second line after frontmatter uses normal insertion path
  • gk on wrapped line after frontmatter jumps straight to properties — when the first line below the frontmatter wraps across multiple display lines, gk now correctly navigates through the wrapped display lines before entering the properties panel. Previously, the stuckAtBoundary check in the fork’s findPosV treated display-line movement within a wrapped line as “stuck” (same document line) and immediately fired focusBefore. The check now also verifies that the cursor offset truly didn’t change (range.head === startOffset), distinguishing “cursor moved to a higher display line within a wrapped line” from “cursor is truly stuck at the frontmatter boundary.” (#25)
  • let mapleader = " " (space) not working as leader key — space as leader now works regardless of which features are enabled. The default <Space> → l binding in codemirror-vim’s keymap consumed the space keystroke before leader-prefixed sequences could accumulate. Previously, unmapDefaultBinding(leader) was only called inside registerEasyMotion(), so the fix only applied when EasyMotion was enabled. The plugin now unmaps the leader key’s default binding centrally — after vimrc loading, in reregisterLeaderFeatures(), and in reloadFeatures() — so any key used as leader (space, comma, semicolon, etc.) works for all leader-dependent features (table manipulation, hint mode, settings leader bindings) even when EasyMotion is disabled. (#21)
  • Mislabeled “space as leader” e2e test — the describe('space as leader') test block was loading let mapleader = "," instead of let mapleader = " ", making it a duplicate of the comma test rather than a true space leader test. Fixed to use space, providing actual cross-platform regression coverage.
  • E2E regression tests for gk wrapped-line frontmatter edge case: gk navigates display lines on wrapped first content line, gk enters properties on non-wrapping first content line, k enters properties from first content line

Changed

  • Settings tab reorganized — settings are now grouped under section headings for easier navigation: Vim features (text objects, structural navigation, hard-wrap, smart list continuation, table navigation, table widget mode, workspace navigation), Jump navigation (EasyMotion, hint mode, shared label font size), Status bar (mode indicator, chord display, powerline, mode prompts), Cursor shapes, Vimrc & key bindings (vimrc toggle, leader key bindings), Which-key hints (mode, grouping, group labels), Advanced (scrolloff, multi-line scan range). Previously, settings appeared as an undifferentiated list with only a few headings.
  • EasyMotion label characters — now exposed as a dedicated text field in the Jump navigation settings section. Previously only configurable by knowing the default value.

Documentation

  • KNOWN_LIMITATIONS.md: added “Smart list continuation and frontmatter” section documenting the O boundary fix
  • DIFFERENCES.md (fork): added “Frontmatter-aware O (open line above)” section documenting the newLineAndEnterInsertMode fix
  • README.md: updated smart list continuation description to mention frontmatter awareness
  • README.md: updated settings list to reflect new section grouping and ordering
  • KNOWN_LIMITATIONS.md: updated “Properties navigation” section with wrapped-line stuckAtBoundary edge case fix
  • DIFFERENCES.md (fork): updated “Properties navigation” section with range.head === startOffset guard

[0.20.0] - 2026-06-29

Fixed

  • let mapleader = "," (comma) and other keys with default Vim bindings not working as leader for EasyMotion — unmapDefaultBinding now passes { includeDefaults: true } to vim.unmap(), so built-in codemirror-vim bindings (e.g. , → repeatLastCharacterSearch, ; → forward repeat) are actually removed before registering EasyMotion mapCommand multi-key sequences. Previously, vim.unmap() silently skipped _isDefault keymap entries, meaning the default single-key binding consumed the first keystroke before the multi-key sequence (e.g. ,,w) could accumulate. Space as leader was unaffected because the default <Space> binding uses angle-bracket notation which doesn’t collide with literal space in commandMatch. (#6)
  • gg/G and other keymaps intermittently stop working — comprehensive vim state hardening across the codemirror-vim fork and plugin to prevent keymaps from breaking until app reload. Root causes identified and fixed: stale normal-mode key prefix state persisting across focus changes, global singleton keymap corruption via unmap() removing default entries, incomplete leaveVimMode() cleanup leaking insert-mode listeners, and async motion race conditions. (#18)
    • Fork: blur handler resets partial key prefixes — the CM6 ViewPlugin now registers a blur listener on contentDOM that calls clearInputState() when the editor loses focus in normal mode. A stale prefix like g no longer persists across tab switches or modal opens, preventing the next keystroke from being silently swallowed.
    • Fork: leaveVimMode() cleanup hardened — now removes insert-mode change/keydown listeners if the editor was destroyed while in insert mode, clears the global lastInsertModeKeyTimer, clears virtualPrompt, and resets inputState before nulling cm.state.vim.
    • Fork: default keymaps protected from unmap() — default keymap entries are tagged with _isDefault and a frozen snapshot is stored at module init. unmap() now skips default entries unless explicitly requested. New Vim.resetKeymap() API restores defaults from the snapshot while preserving user mappings. mapclear() updated to use the _isDefault flag instead of fragile index-based partitioning.
    • Fork: async motion generation tracking — _commandGeneration counter on vim state prevents stale async motion callbacks from executing after a newer command has already run. Protects EasyMotion operator-pending mode (d + easymotion) from race conditions.
    • Plugin: pane-switch state reset — active-leaf-change handler now clears pending vim input state on all editors when switching panes, preventing partial commands from leaking across editors.
    • Plugin: resetKeymap() on load — calls Vim.resetKeymap() during plugin onload() to ensure a clean keymap baseline on plugin enable/reload, recovering from any prior corruption in the same app session.

Added

  • Which-key leader grouping — leader key bindings in the which-key overlay are now grouped by prefix key, matching Neovim’s which-key plugin behavior. When grouping is enabled (default), pressing the leader key shows collapsed groups (e.g. t → Table (+11), \ → EasyMotion (+17)) instead of listing every binding individually. Pressing a group key drills down to show only bindings within that group. Configurable via Settings → Vim Motions → Which-key leader grouping (grouped / flat). (#27)
    • Groups are sorted first in the overlay, followed by ungrouped single-key bindings
    • Group rows are visually distinct (accent color, italic) via the .vim-motions-which-key-group CSS class
    • Grouping applies to all completions in “all partial keys” mode, not just leader-scoped bindings — any multi-key prefix (g, z, [, ], custom mappings) can be grouped
    • Drill-down works in both “leader key only” and “all partial keys” which-key modes
  • Which-key group labels — configurable names for key groups in the which-key overlay. Prefix keys can be labeled (e.g. \t → Table, gr → LSP) instead of showing the generic +N keys text. Built-in features register default labels (Table, EasyMotion) that can be overridden. Labels support <leader> token expansion (e.g. <leader>t resolves to the actual leader key + t). Configurable via Settings → Vim Motions → Which-key group labels.
  • E2E test suite test/specs/vim-state-hardening.e2e.ts with 7 tests: blur prefix recovery, gg/G after plugin reload, keymap protection via unmap(), resetKeymap() recovery after force-unmap, leaveVimMode cleanup from insert mode
  • Fork unit tests: 10 new tests for async motion generation tracking (superseded motion discarded, superseded delete discarded), keymap protection (unmap skips defaults, unmap removes user mapping preserving default, unmap gg preserves default, resetKeymap restores after force-unmap, resetKeymap preserves user mappings, mapclear preserves defaults), leaveVimMode cleanup (clears input state, cleanup from insert mode)
  • Fork test count: 1672 (up from 1660)

Documentation

  • DIFFERENCES.md (fork): added sections for blur handler, leaveVimMode cleanup hardening, default keymap protection (_isDefault tagging, resetKeymap(), mapclear() update), async motion generation tracking, clearInputState API exposure
  • KNOWN_LIMITATIONS.md: added “Vim state hardening” section documenting the multi-layered defense against intermittent keymap breakage
  • README.md: added “Improved vim state reliability” bullet to recommended setup section

[0.19.0] - 2026-06-27

Fixed

  • k/gk do not enter frontmatter navigation — both k and gk now enter the properties panel when the cursor is at the top of a note. Two fixes: (1) the fork’s moveByDisplayLines was missing the focusBefore check that moveByLines already had, and (2) the fork’s findPosV frontmatter detection only triggered when the cursor moved into the frontmatter region (pos.line < start.line), but when the properties widget replaced the frontmatter lines, the cursor couldn’t move up at all — now also triggers at the boundary (pos.line === start.line). Additionally, the plugin’s tableAwareMoveUp motion (which overrides k for table separator skipping) bypassed findPosV entirely — it now delegates to findPosV when the target line is inside the frontmatter. (#25)
  • gk/gj over headings resets cursor to column 0 — gk (and gj) no longer jumps to the beginning of the line when crossing Obsidian headings in live preview. Headings are rendered with larger fonts, making them visually taller. The fork’s findPosV widget-detection heuristic falsely treated the multi-line jump caused by the heading’s height as a skipped replaced widget (e.g. MathJax) and overrode the cursor position. The heuristic now checks for actual replaced/widget decorations (dec.point === true) before activating, and a posAtCoords fallback corrects cases where moveVertically misresolves the goalColumn on decorated lines. (#26)

Added

  • gD — open link in new tab — gD opens the link under the cursor in a new tab, using the same bracket-aware link detection as gd. External URLs open in the browser. (#23)
  • <C-w>gd / <C-w>gD — open link in split — <C-w>gd opens the link under the cursor in a horizontal split, <C-w>gD in a vertical split. Follows the Neovim <C-w>s/<C-w>v convention (lowercase = horizontal, uppercase = vertical). (#23)
  • E2E tests for gD, <C-w>gd, <C-w>gD: link-on-wikilink navigation (new tab, horizontal split, vertical split), no-op outside links, leaf count verification
  • E2E tests for gk/gj over headings: cursor horizontal position preserved across single and multiple headings, symmetry between gk and gj

Documentation

  • KNOWN_LIMITATIONS.md: updated “Properties navigation” section with k/gk frontmatter fix and tableAwareMoveUp interaction
  • KNOWN_LIMITATIONS.md: added gk frontmatter entry to behavioral deviations table
  • DIFFERENCES.md (fork): updated “Properties navigation” section with boundary detection and dual-case focusBefore logic
  • KNOWN_LIMITATIONS.md: updated “Visual line navigation and replaced widget decorations” section with heading-aware fix and posAtCoords fallback
  • KNOWN_LIMITATIONS.md: updated gj/gk widgets behavioral deviation entry with heading decoration handling
  • README.md: added gD, <C-w>gd, <C-w>gD to workspace keyboard control table

[0.18.0] - 2026-06-27

Fixed

  • EasyMotion dimming not visible — the shade overlay (.vim-motions-easymotion-shade) was invisible because it was a child of the zero-size absolutely-positioned wrapper div. The shade is now appended directly to scrollDOM as a sibling of the wrapper, so its right: 0; bottom: 0 resolves against the full editor dimensions. (#6)
  • EasyMotion labels overlapping on dense text — labels for adjacent targets (e.g., <leader><leader>w on closely spaced words) now stack vertically instead of rendering on top of each other. renderLabels() tracks placed label bounding boxes and offsets new labels below any overlap. (#6)
  • EasyMotion labels on hidden text in Live Preview — word-start targets inside hidden markdown syntax (e.g., the URL portion of [text](url)) no longer receive labels. filterVisibleTargets() deduplicates targets whose coordsAtPos() resolves to the same pixel position, which occurs when multiple document offsets map to the boundary of a replaced decoration. (#6)
  • EasyMotion dimming setting required app reload — toggling Settings → Vim Motions → EasyMotion dimming now takes effect immediately. The dimming parameter was changed from a captured boolean to a () => boolean getter, so the shade state is read at motion invocation time instead of registration time.

Added

  • Label font size setting — configurable font size for EasyMotion and hint mode labels via Settings → Vim Motions → Label font size (10–20px slider, default: 14). EasyMotion collision detection scales proportionally with the configured size.
  • Label color customization via CSS — label colors are now overridable via CSS custom properties. EasyMotion: --vim-motions-em-bg, --vim-motions-em-fg. Hint mode: --vim-motions-hint-bg, --vim-motions-hint-fg. All default to --text-accent / --text-on-accent.

[0.17.0] - 2026-06-27

Fixed

  • Visual mode cursor displaced at end-of-line (regression) — exiting charwise visual mode (v$<Esc>, vlll<Esc>) at end-of-line left the cursor one position past the last character. The fork’s exitVisualMode() called clipCursorToContent() while vim.visualMode was still true, which allowed the cursor to land at the linebreak position; after clearing the flag, the cursor remained displaced. Fixed by clearing visual flags before setCursor. Also fixed a latent JS loose equality bug in measureCursor() where false != "\n" evaluated to false due to type coercion. (#15)
  • Leader key mappings not working via vimrc — let mapleader = "," (or space, or any custom leader) in .obsidian.vimrc now correctly re-registers EasyMotion, hint mode, table manipulation, and settings leader bindings with the new leader key. Previously, the initial backslash-leader mapCommand entries persisted in the keymap because Vim.unmap() could not remove mapCommand-created entries, and unmapDefaultBinding skipped non-special keys like comma. The fork now provides Vim.removeMapCommand(keys) for clean removal, and VimRegistration uses scoped leader binding tracking to selectively unregister stale bindings when the leader changes. (#21, #6)
  • <C-w> workspace commands not working — <C-w>v, <C-w>h/j/k/l, <C-w>s, <C-w>c/q, and <C-w>o now work correctly when Obsidian’s default Ctrl+W hotkey is unbound. The fork’s matchCommand had an idle entry for <C-w> in normal mode that consumed the key as a no-op before the second keystroke could arrive, preventing multi-key <C-w>X sequences from matching. The fork now deprioritizes idle full matches when more-specific partial matches exist (e.g. <C-w>v, <C-w>h registered via mapCommand). The idle entry still fires when no sub-commands are registered, preventing the keystroke from propagating to the browser. (#20)

Added

  • Fork regression test exit_visual_mode_cursor_clipping covering vlll<Esc>, vll<Esc>, and v$<Esc> cursor positioning
  • E2E tests for leader key mapping behavior: comma and space leader key mappings execute correctly via Vim.handleKey, leader keys do not insert literal characters in normal mode, EasyMotion overlay appears with custom leader and old leader bindings are cleaned up
  • E2E tests for <C-w> workspace commands: <C-w>v/<C-w>s verify leaf count increases (split created), <C-w>c verifies leaf count decreases (tab closed), <C-w>o verifies other tabs closed, <C-w>h/j/k/l verify focus changes after split, <C-w> followed by invalid suffix (x) verifies the suffix does not execute as a standalone command, insert-mode <C-w> non-regression verifies delete-word still works

Documentation

  • KNOWN_LIMITATIONS.md: updated “Visual mode cursor displaced at end-of-line” section with exitVisualMode root cause and measureCursor coercion fix
  • DIFFERENCES.md (fork): updated “Visual mode cursor positioning at EOL” section with exitVisualMode ordering fix and strict equality fix
  • KNOWN_LIMITATIONS.md: expanded “EasyMotion leader key conflict” fixed section with leader re-registration and removeMapCommand details
  • KNOWN_LIMITATIONS.md: updated “<C-w> prefix conflict” section — removed codemirror-vim limitation framing, kept user-action requirement (unbind Obsidian’s Ctrl+W hotkey)
  • DIFFERENCES.md (fork): added “removeMapCommand API” section documenting the new keymap removal method
  • DIFFERENCES.md (fork): added “Idle key deprioritization for multi-key sequences” section documenting the matchCommand fix

[0.16.0] - 2026-06-27

Added

  • Cursor-aware table editing in Live Preview — replaced the table cell bridge approach with a custom table rendering system. Tables display as themed HTML when the cursor is outside and switch to raw Markdown when editing. All vim motions, operators, and text objects work naturally on table content. (#19)
    • Custom TableRenderWidget renders markdown tables as HTML using Obsidian’s CSS classes (cm-embed-block, markdown-rendered, table-wrapper, table-cell-wrapper) for full theme compatibility
    • StateField provides Decoration.replace for tables the cursor is NOT in; removes decoration when cursor enters
    • Table widget suppressor patches RangeSetBuilder.prototype.add to suppress Obsidian’s interactive table widget
    • Default mode: “Cursor-aware” — rendered table when cursor is outside, raw Markdown when editing
    • “Always raw” mode keeps tables as plain Markdown at all times
    • “Off” mode restores Obsidian’s default interactive table editor
    • Three-way setting: Settings → Vim Motions → Table widget in live preview
    • Supports alignment markers (:---, ---:, :---:) in rendered tables
  • Vertical table cell navigation — ]r/[r moves to the same column in the next/previous row, skipping separator rows
  • Table cell text objects — i|/a| for operating on table cells with standard vim operators:
    • i|: content between surrounding pipes (like i()
    • a|: content plus the trailing pipe
    • Works with d, c, y, v: di| deletes cell content, ci| changes it, yi| yanks it
  • Table realignment — :tablerealign (short: :tablerea) ex command and <Leader>tr mapping. Computes column widths across all rows, pads cells uniformly, and respects :---/---:/:---: alignment markers in separator rows.
  • Table auto-format on | — CM6 inputHandler extension that realigns table columns when | is typed in insert mode. Typing || on a new line within a table generates a separator row (|---|---|).
  • Table manipulation keybindings — <Leader>t prefix commands mapped to Obsidian’s built-in table commands, inspired by vim-table-mode:
    • <Leader>tm — insert table
    • <Leader>to/tO — add row below/above
    • <Leader>tJ/tK — move row down/up
    • <Leader>tdd — delete row
    • <Leader>tiL/tiH — add column right/left
    • <Leader>tL/tH — move column right/left
    • <Leader>tdc — delete column
    • <Leader>tr — realign table
  • Table ex commands — 15 ex commands for table manipulation: :tableinsert, :tablerowafter, :tablerowbefore, :tablerowup, :tablerowdown, :tablerowdelete, :tablecolafter, :tablecolbefore, :tablecolleft, :tablecolright, :tablecoldelete, :tablealignleft, :tablealigncenter, :tablealignright, :tablerealign
  • Internalized monkey-around — src/util/around.ts provides safe prototype patching with automatic removal, replacing the external monkey-around dependency
  • E2E test suite expansion: 28 tests in table-cell-bridge.e2e.ts (cursor-aware rendering, widget suppression, j/k navigation through tables, separator row traversal, post-edit navigation, theme class verification, alignment rendering), 24 tests in tables.e2e.ts (cell navigation, vertical navigation, text objects, realignment)

Fixed

  • Cursor stuck on table separator after insert-mode edit — after editing a table cell in insert mode, Obsidian’s async table handler repositions the cursor, preventing k from crossing the separator row (|---|---|). Fixed with a custom tableAwareMoveUp motion that skips separator rows when moving up after a table edit. The motion detects the snap-back pattern and compensates by jumping two lines (over the separator) instead of one. Operator-pending context (dk) is excluded from the skip to preserve correct delete ranges.
  • Cursor-aware table rendering — the “Cursor-aware” mode now uses a custom read-only TableRenderWidget instead of Obsidian’s interactive table widget. Tables render as themed HTML when the cursor is outside, with no async cursor snap-backs or state corruption.

Changed

  • Replaced TableCellBridge approach (per-cell vim bridge) with cursor-aware table rendering. The bridge approach required maintaining vim state across Obsidian’s cell-scoped editors; the new system suppresses Obsidian’s widget and provides its own themed read-only widget via a StateField.
  • tableWidgetMode setting default: 'cursor' (cursor-aware rendering)
  • Legacy suppressTableWidget: boolean setting migrated: true → 'always', false → 'off'

Documentation

  • KNOWN_LIMITATIONS.md: updated table navigation section with new features (vertical nav, text objects, realignment, auto-format); documented cursor-aware mode architecture
  • README.md: updated table navigation description for cursor-aware rendering; added i|/a| to text objects table, ]r/[r to navigation, table text objects section, auto-format docs, <Leader>tr and :tablerealign to commands

[0.15.0] - 2026-06-26

Fixed

  • Bundled fork not recognized by other plugins — ecosystem plugins that check window.CodeMirrorAdapter.Vim (e.g. Outliner, obsidian-vimrc-support) could miss the bundled fork due to plugin load order or Obsidian overwriting the property after the bridge was installed. The bridge now uses a property descriptor (getter) instead of a plain assignment, so reads always return the fork’s Vim singleton regardless of timing. The bridge is also installed before registerEditorExtension() for earlier availability, and properly cleaned up on plugin unload. (#17)
  • Vim.enterInsertMode(cm) missing from fork API — ecosystem plugins (Outliner, obsidian-lineage) call Vim.enterInsertMode(cm) to transition the editor into insert mode after custom actions. Obsidian’s built-in vim.js exposes this method but upstream @replit/codemirror-vim does not. The fork now exports enterInsertMode(cm) on the Vim singleton, matching Obsidian’s API surface. Without this, plugins using the bundled fork would get TypeError: vim.enterInsertMode is not a function. (#17)

Changed

  • Fork test count — 1630 fork tests passing (up from 1628). Added api_enterInsertMode test verifying the new public API method.

Documentation

  • DIFFERENCES.md (fork): added “enterInsertMode API exposure” section documenting the Obsidian-specific API addition

[0.14.0] - 2026-06-26

Added

  • Which-key for all partial keys — the which-key overlay now triggers on any partial key sequence (operators like d, c, y and prefix keys like g, z, [, ]), not just the leader key. After a 500ms delay, a multi-column panel at the bottom of the editor shows available continuations. Configurable via Settings → Vim Motions → Which-key hints with three modes: off, leader key only, all partial keys (default: off).
    • Operator-pending mode (d …) shows grouped next-key options: single-key motions directly (w, j, $), multi-key prefixes collapsed (i → +N text objects, a → +N text objects)
    • Partial prefix keys (g …, z …) show getCompletions() results from the fork’s keymap introspection API
    • Special keys (<Left>, <C-n>, etc.) and insert-only entries are filtered out
    • Leader bindings from settings and vimrc are shown with friendly command names
    • Overlay positioned at bottom of editor pane (not viewport), max 40% height, multi-column grid layout
  • E2E test suite test/specs/which-key.e2e.ts with 31 tests covering all three modes (off/leader/all), settings hot-reload, leader registry integration, and fork API integration (getKeymap/getCompletions)

Changed

  • Which-key setting — enableWhichKey boolean replaced with whichKeyMode dropdown ('off' | 'leader' | 'all'). Default changed from implicit leader-only to explicit 'off'.
  • Which-key overlay rewritten — WhichKeyOverlay class generalized from leader-only to support any partial key sequence. Uses getInputState() for operator-pending detection and vim.status for partial key chord display. DOM attachment changed from editorEl.parentElement to view.contentEl for reliable positioning.
  • VimState type fix — mode field changed from required 'normal' | 'insert' | 'visual' | 'replace' to optional string to match runtime behavior (the field is only set by the CM6 ViewPlugin’s mode-change handler, not by the initial vim state).
  • Plugin deviations reduced — test/neovim/deviations.ts reduced from 20 to 10 entries. 10 deviations removed after verifying the fork now matches Neovim: ) sentence motion, di( multiline, db cross-line, dw empty line, d2w cross-line, dge empty lines, diw word boundary, da" trailing space, :join cursor, :global cursor.
  • Fork test count — 17 new fork-level tests for async motion dispatch (6), getKeymap() API (5), and getCompletions() API (6). Total: 1628 fork tests passing.
  • Fork golden comparison — re-recorded 756 golden cases from Neovim 0.12.2 with per-step state capture. 476 pass, 0 unexpected diffs, 280 known deviations (down from 284). Fixed 3 duplicate test name collisions and empty :s flag behavior.

Fixed

  • da" trailing space — da" on say "hello world" end now produces say end (single space) instead of say end (double space). The fork’s findBeginningAndEnd now consumes adjacent whitespace after inclusive quote expansion, matching Neovim.
  • :join cursor position — :join ex command now positions cursor at column 0 of the joined line, matching Neovim. Previously placed cursor at the join point.
  • :global cursor position — :g/pattern/d now positions cursor at the last matched line after execution, matching Neovim. Non-destructive :g commands leave cursor where the last sub-command placed it.
  • Empty :s flag behavior — :s with no arguments no longer preserves the /g flag from the previous substitution. Only the first match on the line is replaced, matching Neovim.
  • % string-awareness — updated KNOWN_LIMITATIONS.md to reflect that % is only partially fixed: the forward-seek check works, but findMatchingBracket still does positional counting without string awareness.
  • Which-key graceful degradation — getInputState(), getKeymap(), and getCompletions() calls in the which-key overlay now check typeof before invocation, preventing errors when built-in vim mode is active (these APIs are fork-only).
  • Cursor shape settings in built-in mode — cursor shape dropdowns are now disabled with an explanatory message when Obsidian’s built-in vim mode is active (cursor shapes require bundled fork mode).
  • g-commands golden data — corrected incorrect Neovim recordings for g$ (cursor ch:11→10, mode visual→normal) and guu (content unchanged→lowercased). Full vim-builtin e2e suite now passes 16/16.

Documentation

  • DIFFERENCES.md (fork): added “Keymap introspection API” section documenting getKeymap() and getCompletions()
  • DIFFERENCES.md (fork): updated “Empty :s flag preservation” → “Empty :s uses default flags”, added da" whitespace, :join cursor, :global cursor sections, updated golden comparison stats
  • KNOWN_LIMITATIONS.md: “Which-key overlay scope” section rewritten to reflect the new all-keys mode
  • KNOWN_LIMITATIONS.md: updated % + strings entry to “Partially fixed” with explanation of remaining findMatchingBracket limitation
  • KNOWN_LIMITATIONS.md: added da", :join, :global, :s empty entries to behavioral deviations table
  • KNOWN_LIMITATIONS.md: corrected vi* single-char status from “Fixed” to “Not fixed”
  • AGENTS.md: updated fork test count (1421→1628) and golden comparison stats
  • README.md: which-key description updated and settings list updated with new dropdown

[0.13.0] - 2026-06-26

Fixed

  • Visual mode cursor displaced at end-of-line — in charwise visual mode (v$, vl to EOL), the block cursor no longer renders one character past the visible line content. The fork’s measureCursor() now uses the vim state (vim.visualLine, vim.visualBlock) to only apply the EOL cursor adjustment in charwise visual mode, preserving linewise (V) and blockwise (<C-v>) rendering. Verified against Neovim 0.12.2 golden comparison. (#15)
  • set clipboard=unnamed not syncing to system clipboard — set clipboard=unnamed (or unnamedplus) in .obsidian.vimrc now actually syncs yank, delete, and change operations with the system clipboard. Previously, the option was parsed and stored but never acted upon — only explicit "+y register yanks reached the clipboard. Paste (p/P) also reads from the system clipboard when the option is set. (#16)

Added

  • Surround operator (vim-surround) — complete vim-surround implementation with all standard features. Requires bundled fork mode. (#9)
    • Core: ds{target} (delete), cs{target}{replacement} (change), ys{motion}{replacement} (add), yss{replacement} (entire line), visual S{replacement} (selection)
    • Tag surround: dst (delete surrounding tag), cst{replacement} (change tag), ysiw<tag> (surround with tag), cs"<tag> (delimiter to tag), visual S<tag> (selection with tag). Regex tag fallback for Markdown mode.
    • Function wrapping: ysiwf + name + Enter → name(text), ysiwF for spaced variant → name( text )
    • Newline variants: cS, yS, ySS, gS — delimiters on separate lines with content indented one level deeper
    • Count support: 2ds) deletes 2nd-level surrounding bracket, 2ysiw* repeats delimiter for Markdown bold (**word**), 2ds* unbolds, 2cs*~ changes bold to strikethrough. Works with any quote-type delimiter (*, ~, =, $).
    • Insert mode: <C-G>s{char} inserts open delimiter, type content, close delimiter appended on Esc. <C-G>S{char} for newline variant.
    • Dot-repeat (.) works for all surround commands including tags, functions, and multi-char delimiters
    • All bracket/quote targets with space rules, aliases (b→), B→}, r→], a→>), and t (tag) target
    • 1585 fork tests passing
  • E2E test suite test/specs/surround.e2e.ts with 66 tests covering ds/cs/ys/yss/visual S, tags (dst/cst/ysiw), function wrapping (f/F), newline variants (cS/yS/ySS/gS), count support (2ds/2cs), Markdown pairs (2ysiw*/2ds*/2cs*~), insert mode surround (s), dot-repeat, and edge cases

Documentation

  • DIFFERENCES.md (fork): added surround operators section with architecture (pendingInput buffer, tag finding, newline variants, count support, dot-repeat, insert mode surround, char-repeat for Markdown pairs)
  • KNOWN_LIMITATIONS.md: “Surround operator scope” section — complete feature set documented with breaking changes
  • README.md: surround keybinding table with all features (tags, functions, newlines, counts, Markdown pairs, insert mode)
  • test/neovim-command-index.yaml: added 46-entry surround section (100% tested)

[0.12.0] - 2026-06-25

Fixed

  • Visual line navigation skips block MathJax in live preview — gj/gk and j/k now navigate into rendered MathJax $$ blocks line by line instead of skipping over them. The fork’s findPosV detects when moveVertically jumps over multiple document lines (indicating a replaced widget decoration) and steps one document line instead, allowing the cursor to enter the widget’s source range. Folded ranges are excluded from correction. (#14)
  • da$ on block math $$...$$ deletes partially — da$ on $$ a + b = c $$ now correctly deletes the entire expression (producing empty string) instead of leaving $$. The $ text object now uses smart disambiguation (same pattern as i*/a*): tries $$ as delimiter first, falls back to $ for inline math. di$ on block math correctly produces $$$$.
  • ) sentence motion at end of text — ) at the end of the last sentence no longer moves the cursor backward to the period; it stays in place, matching Neovim
  • Dot-repeat of cw + typed text — . after cw correctly replays the inserted text (was a test infrastructure issue, not a vim engine bug)
  • Search n/N wrap-around — n after / search correctly wraps to the first match when reaching the end of the document (was a test infrastructure issue)
  • Chord display not clearing on Escape — pending keystrokes (e.g. d) in the status bar now clear when Escape is pressed. The mode tracker now listens to vim-command-done in addition to vim-keypress and vim-mode-change, catching the case where Escape cancels a partial command without changing mode or firing a keypress event. (#2)
  • Cursor text invisible in light mode — the character under the block cursor now uses --text-on-accent (Obsidian’s contrast color) instead of the syntax-highlighted color. Previously, colored text (headings, links) under the cursor was the same hue as the cursor background, making it unreadable in light themes. (#12)

Added

  • Per-mode cursor shapes — configurable cursor shape per Vim mode: block, bar, underline, or hollow. Defaults match Neovim (guicursor): block for normal/visual, bar for insert, underline for replace/operator-pending. Configurable via Settings → Vim Motions → Cursor shapes or vimrc set guicursor=n:block,i:bar,v:hollow,r:underline,o:underline. Requires bundled fork mode. (#13)
  • E2E test suite test/specs/widget-navigation.e2e.ts with 6 regression tests for gj/gk/j/k navigation through rendered MathJax $$ blocks in live preview

Changed

  • i$/a$ text objects now use createSmartDollarTextObject (tries $$ first, falls back to $), matching the same disambiguation pattern as i*/a* with createSmartAsteriskTextObject

[0.11.0] - 2026-06-25

Fixed

  • Visual selection highlight — visual mode selection is now visible when using the bundled fork. The fork toggles a .cm-vimVisual class and scopes its ::selection { transparent } rule to non-visual modes only. (#10)
  • Properties navigation — pressing k at the top of the document now navigates into the properties (YAML frontmatter) panel, matching built-in vim behavior. The fork’s findPosV adapter detects when moveVertically lands the cursor inside the frontmatter region and provides a focusBefore callback that focuses the “Add property” button. (#11)
  • Latex Suite compatibility — bundled vim extension now registered at Prec.highest so its keydown handler fires before Latex Suite’s handlers, preventing duplicate key consumption in large math blocks. (#11)
  • Empty :s flag handling — :s with no arguments now uses default flags (no /g), replacing only the first match on the line, matching Neovim
  • Octal increment disabled — numbers with leading zeros (e.g. 007) now increment as decimal (008) instead of octal (010), matching Neovim’s default nrformats
  • Per-step golden comparison infrastructure — fork’s Neovim comparison now captures state after each key step (1504 steps at 100% coverage), revealing 23 previously hidden behavioral differences
  • Golden recorder reliability — redraw after setCursor prevents stale Neovim state; 80×24 viewport simulation via set columns=80 lines=24 enables accurate display-line motion recording
  • zc/zo fold commands — fold/unfold now use CM6’s foldCode/unfoldCode directly instead of Obsidian’s incremental editor:fold-more/editor:fold-less commands, which operated globally by heading level rather than at the cursor position. za uses toggleFold for robust cursor-based toggling. (#8)

Changed

  • Bundled vim extension registered at Prec.highest for correct key handler ordering with third-party plugins

[0.10.0] - 2026-06-25

Added

  • Bundled codemirror-vim fork — when Obsidian’s built-in vim mode is disabled, the plugin provides a forked @replit/codemirror-vim as a CM6 extension with Neovim-parity behavioral fixes. A window.CodeMirrorAdapter.Vim bridge ensures ecosystem plugins (obsidian-vimrc-support, vim-im-control, etc.) work transparently.
  • Async motion support — the fork’s defineMotion now accepts async functions returning Promise<Pos>, enabling EasyMotion to work natively as a motion instead of an action. Operator-pending (d/c/y + easymotion) and visual mode (v + easymotion) work through the standard vim dispatch.
  • Neovim golden comparison infrastructure in fork — 496/688 tests passing against headless Neovim, with per-step extraction, golden recording, and automated comparison (npx tsx test/neovim/compare.ts).
  • E2E tests for operator-pending easymotion (d/c/y + easymotion w)
  • E2E tests for multiline bracket text objects (di{/di[/di< across lines, same-line verification)
  • E2E tests for % string-awareness, db/d2w cross-line whitespace, dd cursor column preservation, J trailing whitespace
  • Expected-failure test cases for 6 remaining fixable deviations (dw cursor, d2w scope, dge empty, db cross-line, % quoted brackets, N after search)
  • Full vim-easymotion default motion set — all 17 default-mapped motions: find (f, F, s, t, T), word (w, b, e, ge, W, B, E, gE), line (j, k), search (n, N)
  • Bidirectional easymotion variants — easyMotionBdWord, easyMotionBdEndWord, easyMotionBdWORD, easyMotionBdEndWORD, easyMotionBdLine, easyMotionBdTill available as named actions for vimrc remapping
  • Repeat last easymotion motion — easyMotionRepeat action replays the most recent easymotion jump
  • 2-character combo labels — SCTree algorithm assigns single-char labels to nearby targets and 2-char labels to distant targets when there are more targets than label characters (>26). Backspace resets after typing the first char of a 2-char label.
  • Text dimming — non-target text is dimmed when easymotion is active, making labels more visible. Controlled by Settings → Vim Motions → EasyMotion dimming (on by default).
  • Visual mode support — all easymotion motions work in visual mode. v + easymotion extends the character selection to the target, V + easymotion extends the line selection. Uses CM6 dispatch({ selection }) to manipulate the selection range directly.
  • EasyMotion dimming setting — easyMotionDimming toggle in settings UI
  • Spike test test/specs/spikes/spike19-easymotion-visual.e2e.ts investigating CM Vim visual mode and operator-pending feasibility (6 questions answered)
  • E2E test file test/specs/easymotion-comprehensive.e2e.ts with 22 tests covering cursor landing (word, char, line, ge/gE), 2-char labels, dimming, repeat, visual mode, and edge cases (empty document, single word, empty lines, non-existent char)
  • E2E test file test/specs/easymotion-visual.e2e.ts with 4 tests covering visual mode overlay, charwise selection, linewise selection, and escape preservation
  • CSS classes: .vim-motions-easymotion-shade (dimming overlay), .vim-motions-easymotion-label-first and .vim-motions-easymotion-label-second (2-char label styling)

Fixed

  • dd cursor column preservation — cursor now stays at its original column after linewise delete (matching Neovim), instead of moving to first non-blank character
  • J trailing whitespace — join now strips trailing whitespace from the current line before adding the join space, preventing double spaces
  • di{/di[/di< multiline — inner bracket text objects on multiline brackets now preserve the bracket lines (producing a{\n}b instead of a{}b), matching Neovim
  • dj/dk at document boundary — dj on the last line and dk on the first line are now no-ops (matching Neovim), instead of deleting the content
  • :s cursor positioning — cursor after substitute now goes to first non-blank of the last affected line instead of column 0
  • % string-awareness — % now aborts (no movement) when the first bracket candidate found via forward-seeking is inside a string token, matching Neovim
  • db/d2w cross-line whitespace — when a delete crosses a line boundary, the whitespace-only prefix before the cursor is now included in the deletion, matching Neovim
  • dge at document start — ge at the start of the document is now a no-op instead of deleting the character under cursor
  • dge on empty lines — dge on double-empty-lines now deletes both lines (matching Neovim) instead of leaving one
  • ]p tab remainder — ]p with indentWithTabs now preserves remainder spaces when indent doesn’t divide evenly by tabSize
  • EasyMotion visual mode — async motions now properly update visual selection head/anchor instead of just moving cursor
  • EasyMotion escape dismissal — Escape overlay dismissal in e2e tests now uses real DOM events (browser.keys) instead of Vim.handleKey which bypasses the DOM listener
  • Hint mode escape dismissal — same fix as EasyMotion
  • Workspace test isolation — workspace tests now use beforeEach with loadSingleFileWorkspace() to prevent cascading failures from gd navigation
  • Settings reload Y/Q test — uses Vim.handleKey instead of browser.keys to avoid DOM event routing issues after reloadFeatures()
  • Vim cursor styling — fork’s hardcoded #ff9696 cursor color replaced with Obsidian CSS variables (--interactive-accent, --text-on-accent) directly in the fork’s block-cursor.ts with fallbacks for non-Obsidian environments
  • Settings notice — when Obsidian’s built-in Vim mode is enabled, the plugin settings tab shows a callout-style warning recommending to disable it, with an explanation of the fork’s benefits
  • dw on empty line cursor — cursor after dw on an empty line before a whitespace-only line now positions at ch:1 instead of ch:0
  • Ambient type declarations — src/types/codemirror-vim.d.ts provides fallback types for vim(), getCM(), and Vim when the fork’s build artifacts are unavailable (e.g. in the community scanner’s sandboxed environment)

Changed

  • Recommended setup: disabling Obsidian’s built-in vim mode is now the recommended configuration. The plugin’s bundled fork provides Neovim-correct behavior, async motion support, and theme-aligned cursor styling that are not available with the built-in vim engine.
  • EasyMotion architecture — EasyMotion motions are now registered via defineMotion (async, returning Promise<Pos>) instead of defineAction. The capture-phase operator-pending interceptor (src/easymotion/operator-pending.ts) has been removed — operator-pending and visual mode work natively through the fork’s async motion dispatch.
  • EasyMotion module refactored from single easymotion.ts (243 lines) into 6 focused files: register.ts (data-driven registration), targets.ts (direction-aware target finding), labels.ts (SCTree algorithm), overlay.ts (DOM rendering with dimming and re-render support), keypress.ts (key capture with 2-char narrowing), types.ts (interfaces)
  • <leader><leader>w, <leader><leader>j, <leader><leader>f are now forward-only, matching vim-easymotion parity. Previously these scanned the entire visible viewport regardless of cursor position.
  • registerEasyMotion() now accepts a dimming parameter and uses a data-driven EASYMOTION_DEFS array for registration instead of per-motion imperative code
  • showOverlay() returns an OverlayHandle with updateLabels() for dynamic re-rendering during 2-char label narrowing
  • waitForLabel() replaces waitForKey() as the primary label capture function, supporting multi-char labels, backspace reset, and narrowing callbacks
  • Removed test/specs/easymotion-motions.e2e.ts — superseded by easymotion-comprehensive.e2e.ts with correct async test patterns for char-input motions
  • Fork dependency — @replit/codemirror-vim now references https://github.com/saberzero1/codemirror-vim.git instead of a local file path, enabling CI/scanner environments to install without local checkouts
  • reportUnusedDisableDirectives set to off in eslint config to avoid conflicts between local and scanner lint rule sets
  • Added Obsidian to sentence-case brands list in eslint config

Documentation

  • KNOWN_LIMITATIONS.md: EasyMotion operator-pending rewritten — now uses async motions natively instead of capture-phase interceptor
  • KNOWN_LIMITATIONS.md: added 8 behavioral deviation entries for fork fixes (dd cursor, J whitespace, di{} multiline, dj/dk boundary, :s cursor, % strings, db cross-line, dw cursor)
  • KNOWN_LIMITATIONS.md: added “DOM keyboard events not routed after settings reload” and “EasyMotion visual mode label selection via DOM events” sections
  • AGENTS.md: added codemirror-vim fork section with dual-vim architecture documentation
  • README.md: added “Recommended setup” section explaining benefits of disabling built-in vim
  • DIFFERENCES.md (fork): comprehensive rewrite documenting all behavioral fixes and infrastructure changes
  • DIFFERENCES.md (fork): added widget-aware vertical navigation and per-mode cursor shapes sections
  • KNOWN_LIMITATIONS.md: added “Visual line navigation and replaced widget decorations” section
  • KNOWN_LIMITATIONS.md: added “Smart dollar disambiguation” section for $$ vs $ text object matching

[0.9.0] - 2026-06-23

Added

  • Configurable multi-line scan limit — multi-line text objects (i*, a*, i$, etc.) now have a configurable scan range via Settings → Vim Motions → Multi-line text object scan range (5–200 lines, default: 20). Users working with long-form documents can increase the limit to match delimiters spanning more than 40 lines.
  • Code block exclusion in delimiter scanning — the multi-line delimiter scanner now skips lines inside fenced code blocks (``` fences). Delimiters like ** inside code blocks are no longer matched as text object boundaries.
  • E2E test for delimiter scanning across code block boundaries (di* should not match delimiters inside fenced code blocks).
  • E2E test for vi* on single-character content (*x*), documenting the codemirror-vim visual mode limitation.

Fixed

  • Scrolloff dynamic line height — scrolloff margins now use EditorView.defaultLineHeight to measure the actual line height instead of assuming 22px. The margin adapts automatically when the user changes font size or line height via CSS/themes.
  • adjustRangeForVisualMode no longer produces zero-width selections for single-character text object ranges — the −1 head compensation is skipped when the range is exactly 1 character wide. (The underlying codemirror-vim makeCmSelection bug still prevents vi* on *x* from selecting correctly, but di* on *x* now works as expected.)

Changed

  • getTextwidth() now reads directly from the plugin’s internal textwidthValue instead of querying vimApiRef.getOption('textwidth'), avoiding a dual-source ambiguity where CM Vim’s internal option state could return a stale default (80).
  • Vimrc loader skips vim.handleEx() for set textwidth=N lines and handles them entirely via setTextwidth() + vim.setOption(), preventing CM Vim’s Ex handler from interfering with the plugin’s textwidth state.
  • syncTextwidthFromVim() removed — the function read CM Vim’s getOption('textwidth') which returned the stale default (80) during the active-leaf-change lifecycle, overwriting the correct vimrc-set value.
  • findFenceLines() and findContainingBlock() exported from src/text-objects/code-block.ts for reuse in delimiter scanning.
  • MULTILINE_SCAN_LIMIT constant removed from delimiter.ts — scan limit is now passed as a parameter through the text object factory chain (createMultiLineDelimiterTextObject, createSmartAsteriskTextObject, registerTextObjects).

Documentation

  • KNOWN_LIMITATIONS.md: “Scrolloff line height assumption” marked as fixed.
  • KNOWN_LIMITATIONS.md: “Multi-line delimiter scan limit” updated to note the limit is now configurable via settings.
  • KNOWN_LIMITATIONS.md: “Multi-line delimiter nesting” updated to note fenced code blocks are now excluded from the scan.
  • KNOWN_LIMITATIONS.md: “Visual mode on single-character text objects” updated from “Under investigation” to “Confirmed codemirror-vim limitation” with detailed root cause.
  • KNOWN_LIMITATIONS.md: “set textwidth via vimrc” root cause refined — identified CM Vim’s defineOption callback resetting the value during editor initialization.
  • KNOWN_LIMITATIONS.md: “dG leaves trailing newline” updated from “Skipped test, pending fix” to “Unfixable from plugin code” with investigation findings.
  • KNOWN_LIMITATIONS.md: “Dot-repeat of cw” and “n/N search wrap-around” updated from “pending fix” to “Confirmed codemirror-vim bug, not a test timing issue.”

[0.8.0] - 2026-06-23

Added

  • Vim chord display — pending keystrokes (e.g. 2d, gq, <C-w>h) are shown in the status bar as you type a multi-key command, clearing when the command completes or is cancelled. Reads codemirror-vim’s internal vim.status string directly, avoiding event-ordering issues with manual keystroke accumulation in the CM6 adapter. Togglable via Settings → Vim Motions → Vim chord display (on by default). (#2)
  • Customizable mode prompts — per-mode status bar text is configurable via four text fields in Settings → Vim Motions → Vim mode display prompt (normal, insert, visual, replace). Defaults to NORMAL/INSERT/VISUAL/REPLACE. Supports emoji (e.g. 🟢 for normal). (#3)
  • Powerline-style status bar — optional colored mode indicator with per-mode background colors (gruvbox-inspired: green/normal, teal/insert, amber/visual, red/replace) and a CSS border-triangle separator. No special font required — uses pure CSS. Togglable via Settings → Vim Motions → Powerline-style status bar (off by default). Colors are overridable via CSS custom properties (--vim-pl-normal-bg, --vim-pl-normal-fg, etc.).
  • Left-aligned status bar — the vim mode indicator and chord display are always positioned at the leftmost edge of the status bar via DOM reordering and margin-right: auto, matching the convention established by obsidian-vimrc-support.
  • ModePrompts interface and DEFAULT_MODE_PROMPTS constant exported from settings.ts.
  • VimModeTrackerOptions extended with powerline and modePrompts fields.
  • CSS classes: vim-motions-chord, vim-motions-powerline, vim-motions-statusbar-end.
  • Hint mode expanded into a full vimium-style UI navigation system (#7):
    • Smart label length: single-character labels (from home row) when 9 or fewer targets, two-character labels for more.
    • Configurable hint characters: new hintModeLabels setting controls the character pool for hint labels (default: asdfghjkl).
    • Independent settings toggle: enableHintMode setting allows toggling hint mode on/off independently from workspace navigation.
    • Obsidian command: registered as vim-motions:show-hint-labels — triggerable from command palette, assignable via Settings → Hotkeys, and usable without an open note.
    • Global hotkey: press-to-record hotkey setting that works even when modals (settings, command palette) have focus. Uses capture-phase DOM listeners that bypass Obsidian’s scope system.
    • Multi-window support: global hotkey listener registered on workspace popout windows via window-open event.
    • Editor pane navigation: .workspace-leaf-content is now a hint target. Selecting it calls setActiveLeaf() with focus and activates the editor, matching click-to-focus behavior.
    • Smarter element activation: contenteditable elements receive .focus(), internal links use app.workspace.openLinkText(), Ctrl/Cmd+click opens in new pane via MouseEvent dispatch.
    • Backspace reset: pressing Backspace after typing a wrong first character undims all labels and allows re-selection.
    • First-char mismatch dismissal: pressing a character that matches no label immediately dismisses the overlay instead of waiting for a second character.
    • Auto refocus: after hint mode completes, the active editor is refocused (150ms delay) so <leader><leader>h works for the next invocation.
  • Hint mode target selectors expanded from 9 to 24, covering: checkboxes, ribbon icons, callout folds, settings navigation items, settings controls (buttons, toggles, dropdowns), tab close buttons, search inputs, editor panes, internal links in live preview, and modal close buttons.
  • Selectors grouped by stability: standard HTML selectors (stable across Obsidian versions) and Obsidian-internal selectors (may change between versions).
  • generateHintLabels(), HOME_ROW, ALL_KEYS, and TARGET_SELECTOR exported from hint-mode.ts for testability.
  • E2E test suite test/specs/hint-mode.e2e.ts with 13 tests across two tiers:
    • Tier 1 (baseline): overlay appearance, label rendering, Escape dismissal, first-char dimming, label completion, unmatched-char dismissal, Backspace reset.
    • Tier 2 (behavior contracts): home-row first characters, no duplicate labels, consistent label length, visibility filtering, pointer-events CSS, Obsidian command registration.
  • formatHotkey() utility in settings.ts for displaying serialized hotkey strings in human-readable form.
  • CSS class .vim-motions-hotkey-display for the hotkey display in settings.

Changed

  • Hint mode registration extracted from registerWorkspaceNavigation() into a standalone registerHintMode() private method on the plugin, following the same pattern as registerEasyMotion().
  • createHintModeAction() now accepts an optional hintChars parameter for configurable hint character pools.
  • isVisible() now checks against scrollable ancestor containers (not just the viewport) — elements scrolled out of view inside overflow: hidden/scroll/auto parents are excluded.
  • showHints() refactored to use getHintPosition() which places .workspace-leaf-content labels at the editor/preview content area (8px inset) rather than the top-left of the leaf container.
  • waitForHintKey() now returns HintResult with ctrlKey/metaKey modifier state for new-pane activation support.
  • activateElement() replaces the previous bare .click() with context-aware activation (focus, link resolution, modifier-based new-pane, setActiveLeaf).
  • Pop-out window compatibility: window.innerHeight/scrollX/scrollY replaced with activeWindow.* equivalents throughout hint mode.
  • Hotkey recorder uses e.code as fallback when e.key reports 'Unidentified' (common for Ctrl+Space on Linux with input methods).

Fixed

  • Hint mode now works when no note is open (via the Obsidian command path).
  • Hint mode global hotkey now fires even when a modal (settings, command palette) has focus — uses capture-phase keydown listeners on the main window’s document that bypass Obsidian’s scope system.
  • Selecting a .workspace-leaf-content hint now properly focuses the editor pane via app.workspace.setActiveLeaf() instead of a bare .click() that Obsidian didn’t treat as a pane activation.
  • Settings controls (toggles, buttons, dropdowns, navigation items) are now targetable via hint mode.
  • Tab close buttons (.workspace-tab-header-inner-close-button) are now targetable via hint mode.
  • Elements inside scrollable containers (e.g., settings content area) that are scrolled out of view no longer receive hint labels.

[0.7.0] - 2026-06-22

Fixed

  • EasyMotion (<leader><leader>w/j/f) and hint mode (<leader><leader>h) now work with any leader key, including space (let mapleader = " ") and comma. Previously, leader keys with default Vim bindings (space → forward char, comma → reverse repeat find) were consumed immediately by codemirror-vim before the multi-key sequence could accumulate. Fixed by unmapping the leader key’s conflicting default binding before registering EasyMotion mapCommand entries. (#6)
  • Vimrc let mapleader = " " (space) now correctly sets the leader key. The parser previously split the line by whitespace, losing the space inside quotes. Added regex-first parsing for let to preserve quoted values containing whitespace.
  • Vimrc loading no longer falsely reports “loaded but contained no commands” when the editor isn’t ready. loadVimrc now distinguishes “editor not available” (ready: false, retries on next event) from “file parsed with 0 commands” (ready: true). Includes a retry loop (up to 10 attempts, 100ms apart) to handle the race between active-leaf-change and editor initialization.
  • Leader-dependent features (EasyMotion, hint mode) are re-registered after vimrc loading resolves the leader key, ensuring they use the user’s configured leader instead of the default backslash.
  • Visual mode selection on markdown text objects (vi*, va*, vi$, va$, vi~, va~, vi=, va=, vi_, va_, vi`, va`, vil, val, viC, vaC, viB, vaB, vio, vao, vit, vat) now selects the correct range — previously selected one character too far to the right. Operators (d, y, c) were unaffected. Root cause: codemirror-vim’s makeCmSelection adds +1 to the head position in visual mode, and built-in text objects compensate via an internal expandSelection helper, but custom defineMotion text objects bypassed that path. (#4)
  • ]b with a single buffer no longer opens a stale file from a previous session’s recent-files list.
  • vgq (visual mode gq) no longer triggers macro recording. The vim-keypress handler for macro recording previously intercepted the q keystroke in gq as a macro-record toggle. Fixed by restricting macro recording to normal mode only (matching Vim behavior), tracking previous keypress to detect g-prefixed operator sequences, and cancelling pending record state on mode changes. (#5)

Added

  • VimRegistration.unmapDefaultBinding(key) — removes a key’s default codemirror-vim binding (e.g. <Space> → l) so mapCommand multi-key sequences starting with that key can accumulate in the input buffer.
  • VimrcLoadResult.ready field — distinguishes “editor not available” from “file parsed successfully”, enabling reliable retry logic for vimrc loading.
  • E2E tests for EasyMotion with space and comma as leader keys, verifying the unmap + mapCommand approach works for keys with default Vim bindings.
  • E2E test for EasyMotion surviving settings hot-reload (disable → re-enable cycle).
  • getSelection() test helper for asserting exact visual mode selections.
  • loadSingleFileWorkspace() test helper using obsidianPage.loadWorkspaceLayout() to set up deterministic single-file workspace state with an empty recent-files list.
  • 14 new E2E tests verifying exact visual mode selection for all delimiter-based text objects (*, $, ~, =, _, `), plus regression guards for operator mode.
  • E2E tests for gq in visual mode (wrap + no macro recording), gqq macro non-interference, and standalone q macro recording start/stop.
  • 3 Neovim golden comparison cases for gq operators (gqq, Vgq, gqj) added to the g-commands suite with content deviation registered (Markdown-aware wrapping differs from Neovim’s plain-text gq).

Changed

  • registerEasyMotion() now calls reg.unmapDefaultBinding(leader) before registering mapCommand entries, allowing any single-character leader key to work.
  • registerWorkspaceNavigation() hint mode binding uses the resolved leader key from LeaderRegistry (same approach as EasyMotion).
  • createHintModeAction() return type narrowed from ActionFn to () => void (the function ignores all parameters).
  • VimrcLoadResult gains ready: boolean field; loadVimrc() returns ready: false when the editor adapter is unavailable.
  • Vimrc active-leaf-change callback retries loadVimrc up to 10 times when the editor isn’t ready, then re-registers leader-dependent features after successful load.
  • KNOWN_LIMITATIONS.md: “EasyMotion leader key conflict with mapCommand” marked as fixed; added vimrc parser space-handling context.
  • KNOWN_LIMITATIONS.md: added “Visual mode on single-character text objects” section documenting a codemirror-vim edge case where vi* on *x* (1-char inner content) does not select correctly.

[0.6.0] - 2026-06-21

Fixed

  • test/coverage-report.ts — replaced broken regex YAML parser with proper YAML parsing via the yaml package, fixing npm run test:coverage which previously reported 0/0 on the multi-line manifest format.

Neovim deviation closure

  • di*/da* with cursor on delimiter now correctly no-ops — previously the delimiter scanner treated the delimiter position as “inside”, operating on the text. Matches Neovim behavior.
  • diB/daB on nested blockquotes (>>) now correctly scopes to the innermost nesting level — previously deleted all blockquote content regardless of depth.
  • P (paste before cursor) now places cursor on the last pasted character, matching Neovim — previously CM Vim placed cursor one position further.
  • Rewrote gP/gp to use direct register-reading implementation instead of delegating through Vim.handleKey, avoiding re-entrancy issues with the new P override.

Neovim test infrastructure

  • Ex commands (:s, :sort, :d, :yank, :join, :noh, :undo, :redo, :global) now work correctly in Neovim golden comparison tests — added dispatchVimKeys routing that detects Ex command sequences and dispatches them via Vim.handleEx() instead of character-by-character key input.

Changed

  • test/neovim/deviations.ts reduced from 28 to 19 entries (9 removed, 3 new cursor-position deviations added for Ex commands where content is correct but cursor placement differs from Neovim).
  • KNOWN_LIMITATIONS.md behavioral deviations table expanded with 5 entries for confirmed upstream constraints (dG, >>, V+>, d0, <<) that cannot be intercepted via mapCommand due to codemirror-vim’s operator-pending dispatch architecture.
  • Replaced js-yaml dependency with yaml — better maintained, YAML 1.2 spec-compliant, ships its own types.
  • All 16 Tier 1 test files (test/specs/vim-builtin/*.e2e.ts) now use testWithNeovim() as the primary test format alongside existing it() blocks. Neovim lifecycle hooks (startNvim/stopNvim) added to top-level before/after.
  • test/helpers.ts — added vimRawKeys() for raw byte key sequences (supports \x1b for Escape, \x01-\x1a for Ctrl keys, \n for Enter).

Added

Neovim golden comparison testing

  • Neovim-backed golden comparison system for Tier 1 Vim behavior tests, inspired by Zed editor’s NeovimBackedTestContext. Sends identical keystrokes to both Obsidian and a headless Neovim instance, compares resulting editor state (content, cursor, mode).
  • test/neovim/client.ts — Neovim RPC client wrapping the official neovim npm package. Spawns nvim --embed --headless, provides setContent(), setCursor(), input(), getContent(), getCursor(), getMode(), getRegister().
  • test/neovim/compare.ts — state comparison helpers: getObsidianState(), getNeovimState(), compareStates().
  • test/neovim/golden.ts — golden file read/write infrastructure with loadGoldenFile(), saveGoldenFile(), findGoldenCase().
  • test/neovim/deviations.ts — known deviation registry tracking behavioral differences from Neovim. isKnownDeviation() silently allows expected behavioral differences during golden comparison.
  • test/neovim/test-wrapper.ts — testWithNeovim() function: the primary test format for Tier 1 tests. Operates in playback mode (golden files, no Neovim needed) or compare mode (NEOVIM_COMPARE=1, live Neovim).
  • test/neovim/test-definitions.ts — 199 test case definitions across 16 suites covering motions, operators, text objects, editing, yank/put, insert entry, visual mode, g-commands, bracket commands, insert mode, scroll (Ctrl-A/X), and Ex commands.
  • test/neovim/record-golden.ts — standalone script to record golden files from Neovim without running Obsidian. Usage: npm run test:neovim-record.
  • test/neovim/smoke.ts — Neovim client smoke test. Usage: npm run test:neovim-smoke.
  • 16 golden files in test/neovim/golden-data/ recorded against Neovim 0.12.2.
  • npm scripts: test:neovim-smoke, test:neovim-record, test:neovim-compare.

Edge-case test expansion

  • 110 new edge-case tests translated from Neovim’s legacy test suite (test/old/testdir/), replit/codemirror-vim (test/vim_test.js), and VSCodeVim (test/motion.test.ts).
  • Word motion edge cases: w/b/e/ge across empty lines, at document boundaries, with punctuation, count clipping, line wrapping.
  • Operator edge cases: dw at end of line, dd on last/only line, d2w/2dd, D, dk, dj on last line, de/db, dG/dgg, dfx/dtx, cw vs ce, cc/C/2cc.
  • Text object edge cases: iw/aw on whitespace, iW/aW with mixed punctuation, nested i(/i{/i[, di( across lines, d2aw with count, i" with escaped quotes.
  • Character search edge cases: f/t not crossing line boundaries, 2t/2F counts, ; after t, , reversal.
  • Visual mode edge cases: viw, v3l+d, gv reselect, V+y linewise, visual at document boundaries.
  • Yank/register edge cases: yy/yw linewise flag, y$ without newline, numbered register rotation, "Ayy append, ". last inserted text.
  • Repeat edge cases: . after dw/>>/cw+text, 3. with count.
  • Search edge cases: */# wrap-around.
  • Mark edge cases: mark persistence after edit, '. jump to last change.

Documentation

  • README: added “Testing strategy” section describing the Neovim golden comparison system, test types ([nvim]/[obsidian]/Tier 2), and available test commands.
  • KNOWN_LIMITATIONS.md: added “Test-discovered behavioral discrepancies” section documenting 6 bugs found during edge-case test translation (dG trailing newline, iB nesting, di* on delimiter, dot-repeat of cw, ) cursor off-by-one, n/N wrap-around).

[0.5.1] - 2026-06-19

Fixed

  • .obsidian.vimrc is now also loaded on startup, instead of only on leaf change.

[0.5.0] - 2026-06-18

Added

New Vim commands

  • Q — replay last recorded macro (Neovim default, maps to @@)
  • Y — yank to end of line (Neovim default, maps to y$; overrides CM Vim’s yy behavior)
  • ga — show character info under cursor (codepoint, hex, octal) via Notice
  • gp — paste and move cursor past pasted text
  • gn / gN — select next/previous search match (CM Vim native, now tested)
  • g; / g, — jump to older/newer change position (changelist navigation)
  • zO / zC / zA — recursive fold open/close/toggle (maps to Obsidian’s fold commands)
  • it / at — HTML/XML tag text objects, implemented via raw text scanning since CM Vim’s built-in expandToTag is inactive in Markdown mode. Supports single-line, multiline, and nested tags.
  • <C-v> — visual block mode (CM Vim native, now tested)

New Ex commands

  • :e {file} / :edit {file} — open file by name in vault
  • :e! / :edit! — revert current file to saved version
  • :enew — create new untitled note
  • :saveas {file} — save current buffer as new file
  • :update / :up — save current file (alias for :w)
  • :x / :xit — write-if-modified and close
  • :xa / :xall — write-if-modified all and close all
  • :find {file} / :fin — find and open file by partial name match
  • :read {file} / :r — insert file contents at cursor position
  • :b {name} / :buffer {name} — switch to tab matching name
  • :bf / :bfirst — go to first tab
  • :bl / :blast — go to last tab
  • :bw / :bwipeout — close current tab
  • :sp / :split — horizontal split
  • :vs / :vsplit — vertical split
  • :new — horizontal split with new note
  • :vnew — vertical split with new note
  • :tabnew / :tabedit — open new tab (optionally with file)
  • :tabclose / :tabc — close current tab
  • :tabonly / :tabo — close all other tabs
  • :tabfirst / :tabrewind — go to first tab
  • :tablast / :tabl — go to last tab
  • :version / :ve — show plugin version
  • :delmarks {marks} — delete specified marks
  • :changes — show change list in modal

Test infrastructure

  • Shared test helpers module (test/helpers.ts) with setupEditor, getCursorPos, getEditorValue, getRegisterContent, getVimMode, vimKeys, and timing constants
  • unsupported() and deviation() test helpers for documenting known limitations and behavioral differences in test reports
  • Neovim command index manifest (test/neovim-command-index.yaml) tracking 227 commands with tier classification, test status, and test file references
  • Coverage report script (test/coverage-report.ts) — run via npm run test:coverage
  • 16 new test files in test/specs/vim-builtin/ covering normal mode motions, search, editing, yank/put, insert entry, scroll, marks/jumps, g-commands, z-commands, bracket commands, text objects, operators, visual mode, insert mode, and Ex commands
  • 7 spike tests for register access, paste marks, editor extensions, tag text objects, CM Vim Ex command probing, Ex command conflict checking, and vimrc mapping diagnostics
  • Comprehensive E2E test coverage for <C-w>h/j/k/l pane focus, H/M/L screen-relative motions, ? backward search, zO/zC/zA recursive folds, and all new Ex commands
  • E2E test for scrolloff hot-reload: verifies scroll margins update when scrolloffLines changes
  • E2E test for Y/Q independence from workspace navigation: verifies Y still yanks to end of line when workspace nav is disabled
  • GitHub issue templates (bug report, feature request) with required KNOWN_LIMITATIONS.md checklist

Fixed

  • Scrolloff now works correctly — previously used CSS scroll-padding which CodeMirror 6 ignores (it uses manual scroll calculations, not Element.scrollIntoView). Replaced with EditorView.scrollMargins facet, which CM6 respects when scrolling the cursor into view
  • Scrolloff setting now applies immediately when changed in settings — previously required a plugin reload because the slider’s onChange handler did not trigger reloadFeatures() and reloadFeatures() itself had no scrolloff handling
  • Removed deprecated setDynamicTooltip() call on scrolloff slider — the value is now always shown inline by Obsidian
  • Y (y$) and Q (@@) Neovim default remaps now work regardless of the “Workspace navigation” toggle — previously these were registered inside registerWorkspaceNavigation() and would stop working when workspace nav was disabled
  • Vimrc loader now shows a Notice on load: reports the number of commands applied on success, warns when the file is not found, and warns when the file contains no commands
  • Vimrc commands are now processed through codemirror-vim’s Ex command handler (handleEx) instead of the programmatic API, matching obsidian-vimrc-support’s approach for improved compatibility
  • ESLint import/no-extraneous-dependencies error on @codemirror/view — added import/core-modules setting and peerDependencies for @codemirror/* packages provided by Obsidian at runtime
  • Removed unused variables: totalLines in tag.ts, openEndIndex/closeStartIndex in tag.ts, active in commands.ts, newLeaf in commands.ts

Changed

  • Scrolloff implementation rewritten from CSS scroll-padding inline styles to EditorView.scrollMargins extension registered via registerEditorExtension. The ScrolloffManager class no longer manages event listeners or DOM manipulation — it updates a shared margin variable read by the CM6 facet callback.
  • Refactored 8 existing test files to use shared helpers from test/helpers.ts instead of locally defined getEditorValue, getCursorLine, and vimKeys functions
  • Test-vault hotkeys.json now unbinds Obsidian shortcuts that conflict with Vim commands (Ctrl+W, Ctrl+N, Ctrl+P, Ctrl+S, Ctrl+O)
  • Tag text objects (it/at) changed from unsupported skip to working plugin-implemented text objects
  • ChangeList class gains getEntries() and getIndex() public accessors for the :changes Ex command
  • Y and Q Neovim default remaps moved from registerWorkspaceNavigation() to the always-on initialization path in onload() and reloadFeatures()
  • Vimrc loader’s loadVimrc() now returns a VimrcLoadResult with found, commandCount, path, and maps fields
  • Vimrc loader refactored to use vim.handleEx() for command application instead of direct vim.map()/vim.setOption() API calls, improving compatibility with obsidian-vimrc-support configurations
  • Vimrc loader now collects parsed map commands as DeferredMap entries and re-applies them via vim.map()/vim.noremap() on subsequent active-leaf-change events, attempting to restore mappings that CM Vim may lose during editor reinitialization
  • Vimrc loader intercepts set textwidth=N / set tw=N lines and directly updates the plugin’s internal textwidthValue, bypassing CM Vim’s option callback chain
  • getTextwidth() now reads from CM Vim’s option via Vim.getOption('textwidth') as a fallback when the plugin’s internal value hasn’t been updated
  • Vimrc loading deferred to first active-leaf-change event to guarantee editor availability, matching obsidian-vimrc-support’s loading strategy

Documentation

  • README: added :wa / :wall to Ex commands table, g<C-t> to workspace keybindings table
  • README: corrected set textwidth=N claim — now notes the known limitation and provides the runtime workaround via developer console
  • KNOWN_LIMITATIONS.md expanded with comprehensive “Neovim Ex commands not applicable in Obsidian” section covering 30+ commands across 8 categories (shell, quickfix, tags, scripting, diff, etc.) with specific reasoning
  • KNOWN_LIMITATIONS.md expanded with “Behavioral deviations” section documenting 6 commands that work differently from Neovim (Y, Q, :wall, gf, zO/zC/zA, it/at)
  • KNOWN_LIMITATIONS.md: added “nmap L $ does not work via vimrc” section with full diagnostic findings
  • KNOWN_LIMITATIONS.md: added “set textwidth via vimrc does not affect gq” section with root cause analysis
  • KNOWN_LIMITATIONS.md: replaced “Scrolloff cleanup on disable” section with “Scrolloff line height assumption” (22px hardcoded)

[0.4.0] - 2026-06-14

Changed

  • Lowered minimum Obsidian version from 1.13.0 to 1.1.1 — audited all Obsidian API usage and confirmed no API newer than 0.13.8 is required. Users on Obsidian 1.1.1 and later can now use the plugin.
  • Replaced Obsidian’s setCssProps prototype augmentation with standard el.style.setProperty() calls in EasyMotion and hint mode. Removes dependency on an undocumented global API whose introduction version is unknown, improving backward compatibility.
  • Prefixed all plugin-owned CSS custom properties with --vim-motions- to avoid collisions with other plugins or themes:
    • --em-left → --vim-motions-em-left
    • --em-top → --vim-motions-em-top
    • --hint-left → --vim-motions-hint-left
    • --hint-top → --vim-motions-hint-top
    • --hint-opacity → replaced with .is-dimmed CSS class (avoids inline style assignment)

Added

  • E2E tests for blockquote text objects (iB/aB) and callout text objects (io/ao)
  • E2E tests for buffer navigation (]b/[b)
  • E2E tests for EasyMotion interaction (overlay appearance, dismissal, line/char label variants)
  • E2E tests for workspace operations: splits (<C-w>v/<C-w>s), folds (zc/zo/zM/zR), tab navigation (gT), file switcher (gf), rename (grn), backlinks (grr), document stats (g<C-g>)
  • E2E tests for ex commands with effect verification: :q, :wq, :bp, :only, :back, :forward, :explorer, :ls
  • E2E tests for quality-of-life features: status bar mode display (NORMAL/INSERT/VISUAL), which-key overlay, ex command suggest
  • E2E tests for settings hot-reload: toggling text objects, navigation, status bar, and EasyMotion on/off
  • E2E tests for operator edge cases: bullet/numbered/nested list prefix preservation in gq, gqj (two-line wrap), gqip (paragraph reflow)
  • E2E tests for text object edge cases: empty delimiters (****, ~~~~, ====), visual mode selection (vi*), yank (yi*)
  • E2E tests for navigation edge cases: heading levels ]3/]4/[3, ordered list navigation, last-heading boundary, cross-line link jumps

[0.3.0] - 2026-06-14

Fixed

  • gd on wiki links with display names ([[file|display name]]) now correctly navigates to the file instead of creating a new file with the display name in the path
  • gd on wiki links with heading fragments ([[file#heading|display]]) correctly preserves the heading target
  • EasyMotion keybindings (<leader><leader>w/j/f) now work — previously registered as literal <leader> strings in mapCommand which could never match typed input
  • Hint mode (<leader><leader>h) same fix as EasyMotion
  • Leader key bindings configured via settings UI or .obsidian.vimrc now work when workspace navigation is disabled — :ob ex command is registered unconditionally instead of only when workspace nav is on
  • Leader key bindings no longer silently fail when obsidian-vimrc-support is installed — removed unnecessary guard that skipped :ob registration
  • Leader key bindings survive settings hot-reload — :ob is re-registered in reloadFeatures() so it isn’t left as a noop after toggling any setting
  • Which-key overlay now dismisses when a key is pressed after it appears — previously show() reset pendingLeader state, preventing dismissal
  • Which-key overlay no longer leaks active-leaf-change event listeners on destroy
  • ExCommandSuggest is rebuilt after settings hot-reload so the completion list stays current

Added

  • ]c / [c as alternative keybindings for table cell navigation, for keyboards where | requires AltGr or modifier keys
  • EasyMotion and hint mode bindings now appear in the which-key overlay
  • Which-key overlay rebuilds after settings hot-reload

Changed

  • Plugin initialization order restructured: leader key resolution (vimrc loading) now happens before feature registration, so EasyMotion and hint mode receive the correct leader key
  • registerObCommand extracted as a standalone function, called unconditionally in both onload() and reloadFeatures()
  • LeaderBinding now tracks source ('builtin' or 'user') to support selective clearing during hot-reload
  • LeaderRegistry gains clearBuiltinBindings() for clean re-registration during reloadFeatures()
  • registerEasyMotion() and registerWorkspaceNavigation() accept LeaderRegistry parameter

[0.2.0] - 2026-06-13

Fixed

  • Vimrc path now uses Vault.configDir instead of hardcoded .obsidian, supporting custom config directories
  • Setting descriptions use dynamic config directory path
  • :ob with no arguments now opens a searchable modal listing all command IDs instead of logging to the developer console
  • Coexistence E2E test now opens a file before assertions, fixing CI race condition
  • Removed deprecated setDynamicTooltip() call on scrolloff slider

[0.1.0] - 2026-06-13

Added

Markdown text objects

  • i* / a* — inside/around bold (**...**) or italic (*...*), with smart disambiguation
  • i_ / a_ — inside/around italic (_..._)
  • i` / a` — inside/around inline code
  • i$ / a$ — inside/around math ($...$)
  • i~ / a~ — inside/around strikethrough (~~...~~)
  • i= / a= — inside/around highlight (==...==)
  • il / al — inside/around links ([[wikilink]] or [text](url))
  • iC / aC — inside/around fenced code blocks
  • iB / aB — inside/around blockquotes
  • io / ao — inside/around callouts
  • All delimiter-based text objects work across multiple lines (20-line scan limit)

Structural navigation

  • ]h / [h — next/previous heading (any level)
  • ]1–]6 / [1–[6 — next/previous heading by specific level
  • ]l / [l — next/previous list item (same indent level)
  • ]n / [n — next/previous link
  • ]b / [b — next/previous open buffer (tab), with fallback to recent files
  • ]| / [| — next/previous table cell

Operators

  • gq — hard-wrap text at textwidth (default 80) with Markdown-aware prefix preservation (blockquotes, lists, nested structures)
  • gw — same as gq but keeps cursor at original position

Workspace navigation

  • <C-w>h/j/k/l — focus pane left/down/up/right
  • <C-w>v / <C-w>s — split vertical/horizontal
  • <C-w>c / <C-w>q — close current tab
  • <C-w>o — close all other tabs
  • gt / gT — next/previous tab
  • gd — go to definition (follow link under cursor)
  • gx — open URL under cursor in browser
  • gf — open file switcher (quick open)
  • gO — document outline navigator (searchable heading list)
  • grn — rename current note
  • grr — show backlinks to current note
  • gra — context-aware actions for cursor position
  • g<C-g> — show document statistics (words, lines, characters)
  • za / zc / zo — toggle/close/open fold at cursor
  • zM / zR — fold all / unfold all

Ex commands

  • :w / :write — save current file
  • :q / :quit — close current tab
  • :wq — save and close
  • :bn / :bp — next/previous tab
  • :bd / :bc — close current tab
  • :only — close all other tabs
  • :qa / :quitall — close all tabs
  • :wa / :wall — save all
  • :ob {command-id} — execute any Obsidian command by ID
  • :ob — list all available command IDs
  • :sidebar left / :sidebar right — toggle sidebar
  • :explorer — reveal active file in file explorer
  • :buffers / :ls — show all open buffers in a modal
  • :backlinks — show backlinks to current note in a modal
  • :grep {pattern} — search vault for text, show results in a modal
  • :back / :forward — navigate back/forward in history
  • :reg / :registers — show register contents in a modal
  • :marks — show marks and their positions in a modal

EasyMotion / Hop

  • <leader><leader>w — label every word start in the viewport
  • <leader><leader>j — label every non-empty line
  • <leader><leader>f{char} — label every occurrence of a character
  • <leader><leader>h — hint mode (Vimium-style labels for clickable UI elements)

Quality of life

  • Vim mode status bar showing NORMAL / INSERT / VISUAL / REPLACE
  • Macro recording indicator showing RECORDING @{register} in status bar
  • Which-key hints overlay when leader key is pressed
  • Ex command tab completion via Tab key
  • Scrolloff (configurable visible lines above/below cursor)
  • Configurable insert escape sequence (e.g., jk to exit insert mode via set insertmodeescape=jk)
  • Settings hot-reload (toggle features without restarting Obsidian)

Vimrc loader

  • Built-in .obsidian.vimrc support compatible with obsidian-vimrc-support syntax
  • Supported commands: map, nmap, imap, vmap, noremap, nnoremap, inoremap, vnoremap, unmap, set, let mapleader, exmap, obcommand, source
  • Supported set options: clipboard, tabstop/ts, textwidth/tw, shiftwidth/sw, expandtab/et, insertmodeescape/ime
  • Leader key replacement in mappings (<leader> token)
  • Leader key propagation to sourced files

Settings

  • Independent toggles for all feature groups
  • Leader key bindings table (add/remove key-to-command mappings without editing vimrc)
  • Scrolloff slider (0–20 lines)
  • EasyMotion label character customization