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-widgetcount is zero, and no.cm-linecontains a pipe), and the rendered rows match the table source exactly. Editing inside the table is not supported yet andnativeremains 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 tonativewith 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| foolines 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: autoand 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 atscrollLeft: 0through 60lpresses whilenativereached 623, which madeownedstrictly worse. OnescrollIntoViewon the mirrored selection dispatch fixes it.scrolloff=100still disables horizontal scrolling, innativeas well asowned— item 6 is pre-existing and reproduced rather than introduced.- Plugin:
src/vim/table/nested-view.ts
- Plugin:
-
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.activeElementon<body>. Focus is now handed back, but only when the nested editor actually held it:handOffruns 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’slastHPosapplies directly, and3jfrom 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
- Plugin:
-
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: 49afterv,head: 51aftervll), while linewise collapses it to the line start and keeps the range invim.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 afterV, the one case that collapses it. The nested editor gaineddrawSelection(): 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-selectionBackgroundat 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)
- Plugin:
-
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 singleQproduced| 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 singleureverses 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 followinguand.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
- Plugin:
-
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:
ddremoves a row and types no literaldd,urestores it,.repeats it,lmoves the parent’s head by one without touching the document,jmoves one table row and stays inside the table,Vreports visual mode andVjdremoves two rows with the header and surrounding text intact.jmoving 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 atparentHead - 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 —handleKeyreturnsundefinedfor 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
- Plugin:
-
A nested editor lifecycle for
tableWidgetMode: owned, mounted but not yet wired. OneEditorViewper 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 throughrunCleanupswhen 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, andownedremains 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
- Plugin:
-
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-widgetmeasurably gone from the document, parking the cursor in a table still constructs aTableCellEditor, and whileeditMode.tableCellholds 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 atcm.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: afterdestroy()it remains set and the write is still blocked, and clearingeditMode.tableCellis what restores writability.nullis 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
- Plugin:
-
Snippets work inside the owned table surface. A prefix expands on
Tab,Tab/Shift+Tabmove between tabstops,Escapeends 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-modeTabnative 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 isownedwith the bundled engine:nativeis 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
- Plugin:
-
The active snippet tabstop is visible inside a table cell.
mirrorRangecollapses 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 readpage— 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, withmainIndexpreserved soscrollIntoViewfollows the vim head, and the nested editor gainsEditorState.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(newmirrorRanges),src/vim/table/nested-view.ts,src/main.ts
- Plugin:
-
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
- Plugin:
-
The owned table surface derives one layout model from its source, and decorates cells and delimiters from it.
src/vim/table/layout-model.tsis 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 aStateFieldwhere 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 modelrealignTableLineswas measured to share with Obsidian. Cell and delimiter marks carry the column’s alignment as a class; they deliberately avoid.cm-table-widget, which issurface-gate.ts’sTABLE_CELL_SELECTOR.- Plugin:
src/vim/table/layout-model.ts(new),src/vim/table/cell-decorations.ts(new),src/vim/table/nested-view.ts
- Plugin:
-
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
- Plugin:
-
tableWidgetMode: ownednow works while the Neovim backend is connected, as a presentational table. The nested cell editor iscontenteditable=falseand never takes focus, so Neovim keeps owning text, keys, IME composition, floats and the command line —key-delegation.tsalready owns keys from the parent’scontentDOMby 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)
- Plugin:
Changed
-
tableWidgetMode: rawis 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 tonativeinstead —suppressTableWidget: trueandtablewidget=always, in both the stored-settings migration and the separate vimrc/Lua option mapping — while an explicitly chosenrawis deliberately left alone.docs/configuration/vimrc.mdanddocs/configuration/lua-config.mdalso omittedownedentirely and now list all three values. The table-widget migration moved out ofmain.tsintosrc/settings-migration.ts, where its siblings already live, so it is unit-testable.- Plugin:
src/settings-migration.ts(newmigrateTableWidgetMode),src/main.ts,src/settings.ts(both implementations),src/vim/options.ts
- Plugin:
-
skipInTableCells()is deleted — it never skipped anything. It returned the extension unchanged plus a CSS class referenced nowhere instyles.css, so all 14 of its call sites insrc/main.tsread 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’sEditorView.updateListener, which ran 8 times inside a cell across two keystrokes, callingcoordsAtPosandgetBoundingClientRecteach time — and it is now gated onclassifySurface. Four are facet-basedgutter({...})extensions with no per-view condition, suppressed only by the existing.cm-table-widget .cm-guttersrule, which now carries a comment saying so. Four areStateFields or fold-service facets that receive anEditorStateand never see a view. One is a propertiesMutationObserverthat 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: anupdateListenerhas no construction phase, and itsselectionSetguard 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)
- Plugin:
-
nativeremains the default table mode, deliberately. The plan for this release sequence set an explicit acceptance bar for makingownedthe 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) remainnative-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.ownedstays opt-in;nativeis 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.installCellEscapeCaptureregistered adocument-capture keydown handler callingstopImmediatePropagation(), which destroyed the event before the key-delegation listener on the editor could forward<Esc>tonvim_input. Any Neovim command awaiting its second character — afterZ,d,g,", a count or an operator — therefore could not be cancelled, and RPC API requests stalled indefinitely. Measured: the identicalZthen Escape sequence recovers in 3 ms with no cell editor open and never recovers with one, across two Escapes. The capture now skipsstopImmediatePropagation()while a backend other than the bundled fork owns keys, resolved throughisExternalBackendActive(), which the connection lifecycle already maintains. Behaviour with RPC disconnected is unchanged, so the documented two-stepcell-edit(insert)→cell-edit(normal)→table-navEscape 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
EditorViewper cell, so every extension registered throughregisterEditorExtension()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 holdingaareports 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 withStateEffect.appendConfigdispatched from aViewPluginand 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 atisConnected: false, no.cm-table-widgetancestor, document"aa"), so a surface classified in the plugin’s create function readsdocumentfor every cell and gates nothing; the decision and the parse are deferred to the first update on a connected view, which is the mechanismtable-cell-cursor-guard.tsalready 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-basedgutter()API, which has no per-view condition, and are hidden with CSS in cells rather than excluded.- Plugin:
src/util/surface-gate.ts(newclassifySurface),src/treesitter/bridge.ts(GatedTreeSitterBridge),src/main.ts(getTreesitterSurfaceReport())
- Plugin:
-
An unmatched
Tabno longer risks writing into a table row. The first implementation of the above delivered the raw key to the parent’s whole keymap stack viarunScopeHandlers, which passed every snippet assertion and still corrupted the table: with no matching prefix, Obsidian’s indent handler claimed theTaband wrote a literal tab before the row (\t| iznlaa | 11 |). Only snippet commands run now, and an unclaimedTabis 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 enterlastInsertModeChanges— after which.replays a formatter edit, the defect Plan B Step 5 fixed forsync-up. Both realign paths now share one guard.- Plugin:
src/vim/table-operations.ts,src/motions/tables.ts
- Plugin:
-
:tablerealigninside 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 — measuredmounted: 1,focused: false,parentFocused: true. The table was rendered by the child and driven by neither, souand.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
- Plugin:
-
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,
childHead34 → 52 withparentHeadstuck 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, sincecellAtreturns 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 blockyover a 2×2 region already filled the register blockwise with the right text.- Plugin:
src/vim/table/nested-view.ts
- Plugin:
-
Ten table commands did nothing in
tableWidgetMode: owned, silently.:tablerowafter,:tablerowbefore,:tablerowdelete,:tablerowup,:tablerowdown,:tablecolbefore,:tablecolafter,:tablecolleft,:tablecolrightand:tablecoldelete— and their<leader>tbindings — wrap Obsidian command ids that drive its privateTableEditorthrough the table widget, whichownedremoves. Measured with the cursor in a table, all ten returned the document byte-identical inownedwhile changing it correctly innative;:tablerealignwas 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 saysowned, 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
- Plugin:
-
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-cursorlineelements inside.cm-table-widgetin all threecursorlineoptmodes. 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. Thelineandbothmodes are aStateField, which receives anEditorStateand never sees a view, soclassifySurfacecannot withhold them the way it withholds the scrolloff listener; the fix neutralises the background instead.display: noneremains wrong for the reason already recorded —buildCursorlineDecorationsputs the class on the real line viaDecoration.line, so hiding the element hides the cell’s text.- Plugin:
styles.css
- Plugin:
-
ownedno longer reproduces #167 item 6. Withscrolloff=100, horizontal scrolling inside a table is zeroed innative— a pre-existing defectownedused to share. The monospace grid setswhite-space: pre, so the nested editor does not wrap and still scrolls: measuredowned: 67againstnative: 0on 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
ownedmode.mainEditorTableCursorGuardsuppresses the parent’s vim cursor while the caret is inside a table, behind a precondition that queried.cm-table-widget— whichtableWidgetMode: 'owned'removes by construction, measuredwidgets: 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,ownedreportssuppressed=trueconsistently 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-widgetexist transiently inownedbeforesuppressNativeCellEditorclears 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
- Plugin:
-
The vim fork was hiding the caret inside an owned table cell, across an editor boundary.
BlockCursorPlugin.update()setsdisplay: noneon every.cm-cursorLayer:not(.cm-vimCursorLayer)it finds in its ownscrollDOM— right for its own view, wrong for the nested cell editor, which is a separateEditorViewmounted inside thatscrollDOMand draws its caret with plaindrawSelection(). Measured: the nested layer held one caret, was focused, and was inlinedisplay: none, which is also why every probe of that caret reported0x0— a hidden element has no box. The fork now skips layers whoseclosest('.cm-editor')is not its ownview.dom, in bothupdate()anddestroy(); the guard is generic and mentions no table. Verified against a local fork build (display: block, a1x19caret inside the cell’s box) with a negative control that restoresdisplay: noneand0x0, 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(ownsLayerguard inupdate()anddestroy(), documented in the fork’sDIFFERENCES.md) - Plugin:
package.json(alias bumped to^6.4.4)
- Fork:
-
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 — measured1x19in normal, insert and back again, where the parent’s vim cursor is9x19in normal. So a cell shows a caret but not a vim caret, andcursorShapesdoes not apply there. The1x19was 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’sCmAdaptertype does not satisfy the fork’sCodeMirror.
Tests
-
Six scenarios for the
rawdeprecation plus nine unit cases for the migration, with six negative controls intest/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 thealwaysmapping change insrc/vim/options.tswas unverified until twohandleExscenarios were added. Controls 3 and 4 are deliberately asymmetric — one breaks the legacy cases and leaves the explicitrawpassing, 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 hasscrollHeight ≈ clientHeightand the enforcer’s own>= 1threshold means it never writesscrollTopthere — “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
ViewPlugincreate function” sabotage has nothing to act on when the gate lives in anupdateListener, 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 intest/specs/table-boundary-negative-controls.mdfail disjoint sets, which is what shows the hand-off and its guard are separately load-bearing. Recorded there too:3jfrom 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
aarow and notbbafterV, both data rows and not the header afterVj, exactly three characters aftervll, empty after<Esc>. Paired with the pre-existingVjdrow-deletion scenario, so a mirror that renders one range while the operator acts on another is caught from either side. Three controls intest/specs/table-selection-mirror-negative-controls.md, including one fordrawSelection()— 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 intest/specs/table-ime-negative-controls.md. One of them did not fire and led to deleting thecompositionendflush the plan specified: by the time CodeMirror dispatches the committing transactionview.composingis 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.ymlexists 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 intest/specs/table-key-router-negative-controls.md; the caret one did not fire at first becausechildHead >= 0is satisfied by a freshEditorView’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 intest/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 onmounted,docand 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 classifiedtable-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 intest/specs/table-surface-gate-negative-controls.md, including the one that caught a flaw in a test fake rather than in the product: an earlierclosestanswered 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:enqueueInputdoes not chain keys onprepareKeyInput(), which isawait 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 sREQUEST_TIMEOUT_MS, so a request left pending cannot satisfy it; negative-control evidence is recorded intest/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 theTabbranch 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 tosync-up.ts’sminimalDiff— a different defect in a different place.minimalDiffwas left alone. -
The table formatter is now pinned against Obsidian’s own, nine specimens compared byte-for-byte with a live
TableEditor.rebuildTable()innativemode, plus eleven unit cases for fast feedback.realignTableLineshad no unit coverage at all beforehand. The planned change this guards — adisplayWidth()helper replacingString.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
:tablerealignagainst the document, and innativemode 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 newformatTableLines()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 intest/specs/table-block-mirror-negative-controls.md. Control 1 forces the single-range path and fails exactly the two block scenarios while all 13table-key-routerscenarios — includingVjd— keep passing, which is the pairing that proves the block and linewise assertions test different things. Control 2 removesallowMultipleSelectionsand 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 withctrlKey: 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 — twentyis against twentyMs — not by font name, because a theme naming a proportional family in--font-monospacewould 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: 0reset had been added on the theory that CodeMirror’s default.cm-content/.cm-linepadding 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 identicalleft, and the 12px was entirely in width, because a passive row is a blockdivfilling its container while a.cm-lineis sized by CodeMirror’s content box. The assertion was comparing incomparable boxes; it now measures the rendered text via aRange, after which the two agree within 1px and the CSS rule is provably unnecessary. Shipping CSS no test can detect is theskipInTableCells()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-widgeton 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-gatestayed at 2 passing andtable-cell-cursorlineat 3: the nested editor receives noregisterEditorExtensionextensions, 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
WidgetTypeplus two CSS rules on every table render. The step’s prescribed alternative is not implementable as written either, sincetext-alignis 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:
buildTableLayoutdoes 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 prescribesisolateHistory, 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, sinceview.composingstaysfalseafter a syntheticcompositionstart— CodeMirror setscomposingto0there and the getter tests> 0. That precondition assertion is what turned a would-be passing guard into a visible finding. -
isolateHistorywas not used and is not needed.@codemirror/commandsis not a declared dependency, only a transitive one, and ships its own nested@codemirror/statethat TypeScript rejects outright; the annotation is anAnnotationTypeinstance, 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: oneuundoes 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
normalinstead ofvisual, the status bar losesv-line, and a snippet tabstop’s selection reads empty instead ofpage. The mechanism is that mirroring the parent’s selection down is itself a selection update, and an unguarded up-sync reads onlymain.headand 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 whennvimis 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 — soroutedstays0whether 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.
beforeEachselectedownedbefore the connection came up, and the gate is evaluated when the extension slot is built — soisExternalBackendActive()was still false and a fully restored blocker passed every assertion. That also means the original blocker was inert in that ordering:ownedwould 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-editablecontentDOMis a no-op, socontenteditable=falsealone 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
nativemode 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
realignTableLinesrebuilds 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
nativeandownedresult. 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, pinningownedto native’s own defect, and failed the momentownedimproved; 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 nocontextmenuevent on macOS, and a dispatched event produces a DOM.menuon Linux and Windows but none on macOS. It now asserts Obsidian’seditor-menuworkspace 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.defaultPreventedis deliberately not asserted either way — Obsidian cancels the event in order to show its own menu, sotrueis 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 fourcursorOnTableLine === falseassertions and oneparentCursorVisible === 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 stateownedis in — and every one of those specs pinstableWidgetMode = '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,hasFocusand the layer’s child count from inside the fork’sBlockCursorPlugingave one. It identified the flakiness as Obsidian’s transient widget flipping our own predicate, and it identified the remaining defect precisely: inownedthe 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 indrawSel, 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.mdgains an “Owned table surface” section covering what works, what is not supported yet, and the five conditions under which the mode falls back tonative. Its keybinding table is single-sourced indocs/reference/keybindings.mdand transcluded, per the project’s rule against duplicating keybinding tables.docs/configuration/settings.mdlistsownedas atablewidgetvalue and states thatnativeremains 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>trand=. 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 bothnativeconfigurations and is left untouched inowned, becauseownedreplaces the widget that does it. That loss is now documented as a limitation ofownedrather 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 capabilityownedmode 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.mdanddocs/features/tables.mdstated that neither table mode scrolls horizontally, “measured”. The measurement read.cm-table-widget’s.table-wrapperdescendant and the editor’sscrollDOM— two elements that never scroll — rather than.cm-table-widgetitself, which is whereoverflow-x: autolives. 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. Onlyscrolloff=100genuinely breaks horizontal scrolling, and it does so in both modes. -
KNOWN_LIMITATIONS.mdenumerates 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
ownedsetting 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.mddescribedset tablewidget=rawas showing “raw markdown table syntax”. It does not: the widget is hidden with CSS while Obsidian still block-replaces the source, so no.cm-linein the table’s range renders at all and the table is invisible while remaining navigable byj/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.mdgains a “Snippets in a table cell” section, and its existing[!bug]callout now distinguishesownedfromnativeinstead of saying tabstops do not work in tables generally. Thenativelimitation and itsKNOWN_LIMITATIONS.mdentry survive rather than being replaced, because Obsidian’s per-cell editor still owns the keys there. Both the feature page anddocs/features/tables.mdrecord 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.mdno 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). Theselection-mirror.tscontract 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.mdgains a “Tables” section stating what the presentational surface keeps and what it gives up, anddocs/features/tables.mdandKNOWN_LIMITATIONS.mdcarry the same two costs. Plan C’s C3 acceptance — that a cursor push makesnvim_win_get_cursoragree 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.mdstill said the surface falls back tonativeunder “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), thatscrolloff=100kills horizontal scrolling inownedandnativealike (ownedno longer does), and that idle cells render as plain text (they render as a marked-up grid).docs/features/tables.mdcarried the same claims plus a fallback-table row for the Neovim backend. Both now agree with each other and with the code. -
CONTRIBUTING.mdwas missing the entiresrc/vim/table/subtree — twelve modules, not only the three added here — and describedtable-operations.tsas “row/column manipulation”, which moved totable/structural.tswhen that file became the realign guard. The tree is now complete with a one-line purpose for each module, including whycell-decorations.tsmust not use.cm-table-widgetand 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), whichdocs/guides/plugin-integration.mdhas documented in three places without it existing.handleEx('stopinsert')reportedunknownCommand: trueand the noticeNot an editor command ":stopinsert", while:startinsertresolved normally — so the published Better Paste recipes, whose whole point is returning to normal mode after handing insert mode to another plugin, silently left the editor in insert mode. Measured against Neovim 0.12.5 before implementing::stopinsertis indistinguishable from<Esc>on the way out (Axonhelloleaveshelloxwith the cursor on column5either way), and it is a no-op outside insert mode. It deliberately does not route throughdoKeyToKey(cm, '<Esc>')the way:startinsertroutes throughi/A: in normal mode that would run the fork’s idle-normal Escape path and fire the host’s_idleEscapeCallback, which dismisses popovers and blurs non-workspace editors, so a command Vim defines as doing nothing would have visible side effects. It callsexitInsertModebehind aninsertModeguard instead.- Fork:
src/vim.js(defaultExCommandMapentry,exCommands.stopinsert)
- Fork:
Fixed
- A repeated tabstop inside Markdown emphasis lost both cursors and the whole snippet on the first keystroke in Live Preview — the follow-up half of #198. With the reported body
$1 *a$2* *b$2* $0, Tab placed cursors correctly at both$2occurrences, but typingzleft them at5and7instead of4and9, outside the emphasis and outside the snippet’s own field ranges — so CodeMirror dropped the session (selectionInsideFieldreturns false) and a second keystroke produced*az*y *ybz*where*azy* *bzy*was wanted. A repeated tabstop is not an extension: the LSP snippet specification requires the occurrences to be linked (“typing in one will update others too”), VS Code and CodeMirror realise that as simultaneous selections, and Neovim’s ownvim.snippetas one cursor plus mirrored ranges. Two measurements locate the defect outside the snippet machinery — the same body with no markup ($1 a$2 b$2 $0) is correct through every keystroke in Live Preview, and the emphasis body is correct in Source mode. The guard shipped in 1.3.0 covers the tabstop jump; the second exposure is the first edit at a tabstop, where the document change closes the jump’s window and Obsidian’s snap arrives on transactions after it. Against a multi-range selection the snap does not offset each range past its marker as it does for one cursor — instrumentation recorded it collapsing4:4,9:9to8:8and then rebuilding it as4:4,7:7, across two separate dispatches — so the window is now held for the whole macrotask rather than being consumed by the first drop. Arming also covers any document change that leaves the selection inside the active field, and a transaction that re-sets the selection it already has no longer closes the window, because closing it there leaves the snap behind it unguarded.- Plugin:
src/snippets/live-preview-guard.ts(isTabstopEditarming,isMarkerSnapextracted, the window no longer consumed by a dropped snap or by a selection-preserving transaction)
- Plugin:
<Esc>ignored user mappings entirely, so:imap <Esc> …and:vmap <Esc> …did nothing.handleEsc()ran beforematchCommand()and exited insert or visual mode unconditionally, so the mapping table was never consulted for that one key. Neovim honours it — measured on 0.12.5,inoremap <Esc> XYthena<Esc>yieldsaXYbc, where the fork producedabc;xnoremap <Esc> llleaves visual mode active, where the fork returned to normal. Only full matches win: withinoremap <Esc>q ZZand nothing bound to bare<Esc>, Neovim still exits insert mode, and honouring partials here would be worse than a deviation — the fork’s insert-mode partial branch returns consumed without arminginsertModeEscKeysTimeoutfor a non-character key, so a user who bound<Esc>qwould have no way out of insert mode at all. A mapping already being expanded is skipped viakeyToKeyStack, which is what keeps the recursiveimap <Esc> <Esc>falling back to the built-in exit instead of resolving to a no-op.<C-[>now follows the<Esc>mapping and<C-c>still does not, matching Neovim, because<C-[>is Escape — both send0x1b— while<C-c>is a distinct key; the two adjacentkeyToKeyentries therefore differ by a singlenoremapproperty. That property is the whole mechanism:commandMatchesalready skips user entries during anoremapexpansion throughstartIndex, so an explicitif (noremap) return falsein the new resolver was removed after no test could distinguish it.- Fork:
src/vim.js(userEscMappingClaims()consulted byhandleEsc();noremap: falseon the<C-[>and<C-Esc>entries)
- Fork:
Tests
- Four scenarios for repeated tabstops, two of which are controls that must stay green. Red first, against a rebuilt bundle:
keeps both cursors inside their emphasis after typingreturned[{4,4},{9,9}]→[{5,5},{7,7}], andkeeps the repeated tabstop live for a second keystrokereturned*az*y *ybz*against*azy* *bzy*. Both halves of the fix were then sabotaged separately and each failed differently, which is what shows they are not one change: reverting the edit-arming reproduced the original failure exactly, while keeping the arming and consuming the window on the first dropped snap failed partially — cursor one correct at4:4, cursor two still wrong at7:7, text*azy* *ybz*— the signature of a two-transaction cascade with only the first blocked. The markup-free body and the Source-mode case are the controls: were repeated tabstops simply unsupported, both would fail too, and neither moved at any point. (#198)- Tests:
test/specs/snippets/snippet-live-preview-tabstop.e2e.ts(four scenarios, plus a multi-range selection reader —getCursorPosreports only the main range, so the previous assertions could not have seen a second cursor at all)
- Tests:
- Twelve scenarios for
:stopinsertand user<Esc>mappings, five of which are controls that were green before the change and needed their own sabotage. Red first: 7 failed, 5 passed — the four:stopinsertscenarios onunknownCommand: true,inoremap <Esc> XYatabcagainstaXYbc,<C-[>likewise, andvnoremap <Esc> llatnormalagainstvisual. Each of the five pre-existing passes was then broken deliberately and failed alone: dropping thekeyToKeyStackcheck failed only the recursiveimap <Esc> <Esc>(insert, expectednormal); matching partials as well as full matches failed only the<Esc>qscenario (insert, expectednormal); addingnoremap: falseto the<C-c>entries failed only the<C-c>scenario (aXYbc, expectedabc); dropping:stopinsert’sinsertModeguard failed only the normal-mode no-op (cursorch: 1, expectedch: 2); and making the resolver claim every Escape failed 8 of the 12, including both no-mapping controls. A sixth sabotage found dead code rather than a gap: removingif (noremap) return falseleft all 12 green, becausecommandMatchesalready enforces it, so the line was deleted and the suite re-run at 12 passing. The visual scenario asserts against the same keys typed directly instead of a literal column — CM6 reports an exclusive selection head (3) where Neovim reports an inclusive one (2), and a literal there encodes the coordinate convention rather than the mapping; the first draft failed on exactly that and the baseline measurement is what distinguished it from a real defect.- Tests:
test/specs/vim-builtin/insert-escape-mapping.e2e.ts(new),test/specs/vim-builtin/insert-escape-mapping-negative-controls.md(new, including the Neovim oracle table),test/specs/lua-doc-examples.e2e.ts(one scenario for the publishedvim.cmd("stopinsert")recipe)
- Tests:
- A
vim.schedule(stopinsert)scenario was written, found vacuous, and deleted rather than kept. It wrapped the deferred half of the same published recipe and passed against the unfixed build, which is the signal that it proved nothing. A mode timeline located why: with no fix present it readnormalat settle,normalat +200 ms andinsertat +1200 ms, whilenormal! aon its own readinsertat all three — so thewaitUntil(mode === 'normal')was satisfied on its first poll, before the leader mapping had fired at all. The classic shape of a test passing because its subject never ran. It cannot be made honest without an observable window between the callback returning and the scheduled tick, and widening the defer to manufacture one would testvim.defer_fninstead of the documentedvim.schedule. The direct scenario is genuinely red-first (insertagainstnormalon the unfixed build) and covers:stopinsertreached throughvim.cmdfrom a Lua callback, which is the substance.
Documentation
AGENTS.md: a barenpx wdio rundoes not rebuildmain.js, so a source change under test is silently absent and a negative-control sabotage reports green — the plugin-side twin of thecm-buildhelpertrap already recorded for the fork.CONTRIBUTING.md: the guard’s two exposures, and why a dropped snap does not close its window.KNOWN_LIMITATIONS.md: a new### Tabstop placement limitationsentry for tabstops inside a table in Live Preview, and the repeated-tabstop support statement including where Neovim’s presentation differs. Also a new### set tablewidget=raw does not accept typed text inside a table in Live Previewentry under the table-widget section, recording a previously unreported defect found while checking whetherrawwas a workaround: with the cursor inside a cell, a typed character lands at the end of the document.rawmode is CSS-only, so Obsidian’s table decoration stays in the CodeMirror state. Measured with a control matrix —nativein Live Preview andrawin Source mode are both correct on the identical fixture and cursor, which isolates the failure torawin Live Preview. No snippet is involved.docs/features/snippets.md: repeated tabstops documented as linked occurrences, with the table caveat.docs/features/ex-commands.mdanddocs/reference/keybindings.md::stopinsert/:stopiand:startinsert/:startdocumented, including that:stopinsertis a no-op outside insert mode and lands the cursor where<Esc>would.docs/configuration/remapping.mdanddocs/configuration/vimrc.md: new sections on remapping<Esc>, covering the three behaviours a user has to know before binding it — exact matches only,<C-[>following the mapping while<C-c>stays a dependable escape hatch, and a recursive mapping falling back to the built-in exit.KNOWN_LIMITATIONS.md: a new## Only an exact Escape mapping overrides the built-in mode exitentry, placed beside the existingnoremapmapping limitation. The heading spells out “Escape” rather than<Esc>so the deep link fromdocs/configuration/vimrc.mdhas a plain-text anchor — an anchor carrying angle brackets has no working precedent indocs/, and the one existing link to a heading with backticks and slashes (ex-commands#ob--obcommand--execute-obsidian-commands) uses the slugified form instead. It records that the full-match-only rule is a safety property as well as a parity one: the engine’s insert-mode partial branch consumes the key without arminginsertModeEscKeysTimeoutfor a non-character key, so honouring a partial would swallow<Esc>indefinitely and leave no way out of insert mode.AGENTS.md: user<Esc>mappings and the<C-[>-versus-<C-c>asymmetry recorded in the fork description, and a note that a bad probe key can look like a missing feature —Ctrl+Lis Obsidian’s owneditor:toggle-checklist-statushotkey and is consumed atwindowcapture, which is why an insert-mode function keymap bound to it appears never to fire while the same mapping on<A-y>fires normally.
[1.3.1] - 2026-09-29
Fixed
- A
FileTypehandler in your own Neovim config silently disabled the rendered-frontmatter fold from the second note onward —activateDocument()runsfiletype detecton every activation, which re-fires the user’sFileTypehandlers, 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 setsfoldexpr=MarkdownFold(), and a treesitterfoldexprin a personal ftplugin, which is the commoner one.MarkdownFold()is additionally wrong for frontmatter rather than merely different —tag: alphafollowed by the closing---matches its setext-H2 rule, measuredfoldlevel(2)=2, so the properties line starts a fold instead of sitting inside a protected one. The fold expression is now reapplied per activation.foldlevelandfoldenabledeliberately are not: noFileTypehandler writes them, and rewriting them there would undo a user’s ownzm/zMon every pane switch.sync()keeps its early return becauseprepareKeyInput()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)
- Plugin:
- 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
foldlevelset to0whenever Settings → Editor → Properties in document was anything other than Source, which is Obsidian’s default, so a note opened as ifzMhad been pressed.0was 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 as100, and Vim caps an expression fold atMAX_LEVEL,20—foldnestmaxdoes not move that cap, measured at5and10, both still reporting20— so the level that closes the frontmatter and nothing else is19, not0. Raising it to99instead is the other wrong answer: the sentinel never reaches99either, 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 apparentzRon 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
- Plugin:
Tests
- A new spec measures fold state as the activation leaves it, which no existing spec did.
rpc-folds-undo.e2e.tssetsfoldmethod,foldexpr,foldlevel=99andfoldenableand runszRin itsbeforeEach, so the window it measures is never the window the product produced — the same fixture-hides-product shape as thevim.opt.swapfile = falseline removed for the swap-file defect two entries above. Nothing in the new spec sets a fold option or runszR. It also runs withpropertiesInDocumentset tovisible; every other RPC spec setssource, which is the branch that already worked, and the mode has to be in place before connecting becauseNeovimFrontmatterFoldcaches 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-1was expected — both top-level heading folds closed on arrival. Two further controls, each proven independently. Setting the window level to99failed the frontmatter scenario and nothing else, atclosed: -1, closedEnd: -1against1and3, which is what makes that scenario the guard against the tempting fix rather than a duplicate of the others. Settingfoldmethodtomanual, 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 thansetupEditor, 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)
- Tests:
- 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
FileTypehandler installed and a pane switched, the frontmatter line returnedlevel: 0, closed: -1, closedEnd: -1against20,1and3— the plugin’s sentinel replaced by the user’s expression. The handler is a constant0rather 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 theapplyFoldExpression()call failed only the handler scenario, and addingfoldlevelto the reapply failed onlyleaves 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]azMhad 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 overlaprpc-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’sMAX_LEVELcap is what decides that number, and that the fold expression is reapplied per activation becausefiletype detectre-fires the user’sFileTypehandlers.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 reclaimsfoldmethod/foldexpron the mirror window while leavingfoldlevelto you.
[1.3.0] - 2026-09-28
Added
- Every built-in surround character can now be rebound —
vim.obsidian.surround.setandsurroundmaprefused 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 —tandfas targets, where they reach the tag and function-call finders, and<,fandFas replacements, where they open the tag-name and function-name prompts. An alias resolves to its canonical character first, so overriding)also reachesb; leaving it on the built-in would makedsbandds)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 sharedgetCustomSurroundPairresolver behind all seven lookup sites, the relaxedregisterSurroundPair, and eight built-in dispatch guards — five ont/fas targets, three on</f/Fas replacements)
- Fork:
Fixed
- The Neovim backend’s version gate enforced 0.10, not the 0.12 it advertises —
REQUIRED_API_LEVELread12, but 12 is Neovim 0.10’sNVIM_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.luapasseson_rangetonvim_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 withinvalid key: on_range, 0.12.5 reports 14 and accepts it — and Neovim’s ownCMakeLists.txtat 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
- Plugin:
- 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 ascould not start Neovim at "…": <neovim error>. Check the configured path and permissions.The reporter of #199 spent days movingnvim.exebetween directories on advice that could not have helped: the process was already running, which proves the path resolved and executed. Post-spawn failures now readNeovim started at "…" but the connection failed: …and point at the Neovim configuration instead.- Plugin:
src/rpc/neovim-connection.ts
- Plugin:
EACCESis no longer reported as a non-executable binary on Windows — libuv’suv_translate_sys_errormapsERROR_ELEVATION_REQUIREDandERROR_CANT_ACCESS_FILEtoEACCES, while a plainERROR_ACCESS_DENIEDarrives asEPERM. “The binary is not executable” therefore describes the one Windows cause that does not produce it. WindowsEACCESnow names the Run this program as an administrator compatibility flag and non-native paths such as WSL or a mapped drive;EPERMis handled separately and points at permissions and antivirus; and every spawn failure now carries itserrnocode, so an unhandled one is diagnosable from the notice alone rather than being swallowed.- Plugin:
src/rpc/neovim-connection.ts
- Plugin:
- 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 setsswapfilefalse 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
- Plugin:
- 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 abcwhere*az* abcwas wanted. Read out of Obsidian 1.13.7’s bundle: Live Preview hides inactive formatting markers behindDecoration.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 thesnippetRuntimeslot, so it followsenableSnippets) - The first version keyed the guard on
EditorStateidentity 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 recordedmatch: true, hasSel: falseon that transaction and thenarmed: falseon the snap atfrom: 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 inupdatePlugins, ahead of the update listener that arms the guard, so Obsidian’s timer is always queued first.
- Plugin:
csdot-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 finaltof the second word,.produced(test), "test(, )test", "test", having matched that word’s closing quote against the next word’s opening quote. Typingcs"bby 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 inhandleSurroundSubStateapplies 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 — whichfindSurroundingQuotes, scanning backwards for the nearest quote, then takes as an opening quote. Brackets hid it, becausefindSurroundingBracketscounts 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 wherechangeSurroundPairparks it, socsba..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, thesavedReplacementbranch)
- Fork:
- A surround pair dropped from
.obsidian.init.luastayed bound until Obsidian restarted —applyLuaSurroundPairsreturned 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
- Plugin:
surroundmaphad the same gap, and tracked nothing at all — an explicitsurroundunmapworked, but asurroundmapline simply deleted from.obsidian.vimrcsurvived 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 shapevimrcMapKeysandvimrcExmapNamesalready used two functions away.- Plugin:
src/vimrc/loader.ts,src/main.ts
- Plugin:
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 answersnvim_get_api_infowith an accepted level and then exits, so the next in-flight request rejects and the failure lands after spawn. Negative control: dropping thespawnedargument from the outercatch— the pre-fix behaviour — failed it both ways round,but the connection failedreportingExpected: true, Received: falseandcould not start NeovimreportingExpected: false, Received: true, with nothing else in the spec changing. The spec’sACCEPTED_STUB_API_LEVELrestatesREQUIRED_API_LEVEL, since a spec cannot value-import asrcmodule, and the unit suite pins the two: drifting it to 13 failed on that field alone.- Tests:
test/specs/rpc-lifecycle.e2e.ts
- Tests:
- The RPC test fixture had
vim.opt.swapfile = falsein 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 thebeforeEachhook withNeovim 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 reportedExpected: false, Received: true, and with that assertion inverted so execution reached the disk diff, a freshly created…%test-vault-IX0xSq%Target.md.swpappeared 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
- Tests:
- 7 unit cases in
test/unit/rpc/neovim-api-floor.test.tshold the floor against the keyscompanion.luaactually 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 withfloor is api_level 12; these keys need more: [['on_start',13],['on_win',13],['on_range',14],['on_end',13]]andexpected 14 to be 12. Negative controls for the rest: dropping onlyscripts/install-neovim.shto 13 failed the consistency case on that field alone (installSh: 13againstinstallPs1: 14,prerequisites: 14); disabling the WindowsEACCESbranch 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 12stubbed 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
- Tests:
- 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: 4against an expected3, 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 atch: 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)
- Tests:
- 3 e2e cases and 3 fork cases pin where the
csrepeat 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 todist/, so a negative control there needs a rebuild between the stash and the run — stashingsrc/vim.jsalone leaves the built bundle fixed and all three pass, which is a vacuous green. Rebuilt from pristinemaster,vim_dot_cs_quotes_cursor_before_closing_quotefailed with the same string and the two controls held. (#197)- Tests:
test/specs/surround.e2e.ts,~/Repos/codemirror-vim/test/vim_test.js
- Tests:
- 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_outwardasserts all three states of(((test)))→<((test))>→<<(test)>>→<<<test>>>. It and the#197case 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#197fails 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 overriddenandsurroundunmap should restore an overridden builtin pairboth 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 pristinemaster, the four e2e override cases failed with the built-in output —( hello ) worldfor the paren,<<hello>> worldfor the tag target (the tag finder matching nothing),(hello) worldfor 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 needgetSurroundPairstayed green:alias_follows_targetreturned(hello) world,tag_targetandfunc_targetdeleted 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 failedcustom_surround_override_alias_takes_precedencewith[[hello]] worldforysiwb, and left the other three alias cases green.custom_surround_reserved_char_rejectedasserted the restriction being removed, so it is nowcustom_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-existingvim_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
- Tests:
- The two config-reload releases have their own controls, since neither is reachable from the fork suite. Restoring the
pairs.length === 0early return failedshould restore the builtin surround pair when its override is removed from init.luawith(hello) world, and removing the vimrc release pass failedremoving surroundmap from the vimrc should restore the builtin pairthe same way — whilesurroundunmap should restore an overridden builtin pairstayed green through both, proving the explicit-unmap path and the implicit-removal path are tested separately.loadVimrcSoftexists becauseloadVimrcrestarts 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 atnvim.exerather than its folder, spaces in the path needing no quoting because no shell is involved, the elevation flag behindEACCES, and why WSL,.batwrappers and mapped drives are not valid targets. TheEACCESadvice 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. AnE325row covers the swap files left behind by earlier versions.CONTRIBUTING.md:src/snippets/live-preview-guard.tsin 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 creditingcsdot-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 undervim.obsidian.surround, rewritten and pointed at the feature page.docs/configuration/vimrc.md: a built-in override in the example vimrc, andsurroundmap/surroundunmapin 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>,TreeItemandTreeCollapsibleItembut attachestreetoFileExplorerViewonly. Measured on 1.13.7, five views own an equivalent navigable tree:file-explorer,outline,tag,all-propertiesandbookmarks.changeFocusedItem()movesfocusedItemin all five, whilesetCollapsedis present only infile-explorer,outlineandtag. Resolution is by allowlist rather than by shape, becausebacklink(view.backlink.backlinkDom) andsearch(view.dom) carry aResultDom— a different interface that also haschangeFocusedItem, so duck-typing finds it.ResultDomis not inert: itschangeFocusedItemtakes'forwards'just asTree’s does, and onsearchit moves focus.backlinkis 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 theTableEditorroute 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)
- Plugin:
- Native File Explorer Vim navigation — when workspace navigation is enabled and the native File Explorer has focus, unmodified
h/j/k/lreuse its arrow-key behavior, including counted movement capped at 100. They are ordinaryGlobalMappingRegistryentries, so they remap and unmap like every other global binding:h/lunder a newexplorergate that keeps them from being swallowed outside the tree, andj/kas the existing scroll entries with an explorer branch, since the registry stores one entry per key. Interaction tracking preserves navigation when Obsidian targetsBODYand clears it on focus or pointer movement outside the explorer; pending chords such as<C-w>hare 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)
- Plugin:
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 ofjscrolled the editor 196 px.workspaceNavViewTypesdoes not list those view types, so thestandardgate should have declined, butisPluginLeafActive()askedgetMostRecentLeaf(), which is documented as returning the root-split leaf “while a sidebar leaf might be active” and so reportedmarkdownin every case. The handler now tracks the leaf that actually gained focus throughactive-leaf-changeand prefers it, falling back togetMostRecentLeaf(). 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
- Plugin:
- File Explorer
j/kno longer depend on that bug — they are thestandardscroll 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
- Plugin:
- Multi-digit counts now reach global key bindings — every count typed outside the editor was truncated to its first digit, so
12gtwent to tab 1 and30jscrolled three lines instead of thirty. The first digit is captured in thegateApplies === nullbranch 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 togateApplies === nulland returns there. Single-digit counts were unaffected, which is why this went unnoticed;3gtand 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
- Plugin:
Tests
keeps source-rendered frontmatter fully navigableis fixed, and it was test-side — the spec setpropertiesInDocumenttosource, but something reverted it during the reconnect that follows, so the walk ran withvisibleand stalled at the first body line. The product was correct throughout: the fork’s gate readsgetVaultConfig(app, 'propertiesInDocument') === 'source'live and reportedfalsebecause that is what the config said. The tell was already in the spec — thevisibletest expects exactly the7,6,6,6,6,6thesourcetest was failing with, so the source walk had silently become the visible walk. Forced on Linux, where it had never failed, by injectingsetPropertiesMode('visible')before the walk: byte-identical to both CI observations.measureFrontmatterWalknow 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, wheregetMostRecentLeaf()does reportfile-explorerand 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 consultgetNavViewTypes()failed thehcase and bothh/ltranslations withprevented 0instead of1. - 16 unit cases in
test/unit/workspace/core-view-tree.test.tscover the allowlist, tree resolution, count handling and collapsibility reporting; 12 e2e cases intest/specs/core-view-trees.e2e.tsre-measure every declared row against a real Obsidian and assertbacklink/searchstay excluded. Negative controls: addingbacklinkto the navigable list failed both the pinned-list case and the exclusion case, claimingall-propertiescollapsible failed the pinned-list case, and addingbacklinkto 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 — hoistingchangeFocusedIteminto a local detached it from its receiver and threw onthis.focusedItem, and comparing focus by label reported “did not move” forbookmarks, whose rows render no text. A third, found later: the original survey reportedsearchandbacklinkas inert because it readfocusedItem.selfEl, whichResultDomitems do not have — they exposeel.searchnavigates 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.tscover the sidebar gate:tag/outline/search/backlinkmust not scroll the editor, a main-area nav view still must, and the File Explorer’sjstill moves the tree. Negative controls: removing the active-leaf preference scrolled the editor 77 px in thetagandoutlinee2e cases and failed 4 unit cases; removing the explorer exception left the File Explorerjdispatching no arrow. The unit mock also now collects everyactive-leaf-changesubscriber, because capturing only the last one bound theFileExplorerContextlistener instead of the handler’s and hid the fix entirely. - 4 unit cases in
test/unit/global-key-handler.test.tscover two-digit, four-digit, and trailing-zero counts dispatched to abuiltinaction, plus12gtacross 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:12xdelivered1,9999xdelivered9,30xdelivered3, and12gtdelivered1. 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/lare not swallowed outside the tree,j/kstill scroll there,<C-w>j/k/lstill reach their pane commands,hdisappears when workspace navigation is off, and a usergmap hoverrides the default. Every case was negative-controlled: registeringh/lat thestandardgate gaveprevented 1, dispatched 1instead of0, 0; an unconditional explorer branch gavescrolls 0instead of1; removing the<C-w>jentry gave[]instead of['editor:focus-bottom']; forcingwsNavtrue lefthregistered; blocking registry overwrite gavecommands []instead of['app:go-back']; orderingexplorerahead ofstructuralgaveprevented 0instead of1; removing thefocusinlistener dispatched one unwantedArrowDown; removing the synthetic-event guard reported<ArrowDown>as a physical key; removing the cap dispatched 9,999 instead of 100; and removing thecontentEditablegate gaveprevented 1, stopped 1instead of0, 0. (#191) - A truncated plugin-tarball download no longer kills a CI shard —
scripts/fetch-test-plugins.shpipedcurlintotar, so a response cut mid-stream was unrecoverable: codeload truncated aflash.nvimtarball,tardied withgzip: stdin: unexpected end of file, and the whole Windows shard failed before any test ran. A truncated body is not an HTTP error, socurl -fnever caught it. Each tarball now downloads to disk with--retry 3 --retry-all-errorsbefore extraction.- Plugin:
scripts/fetch-test-plugins.sh
- Plugin:
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 thattest/specs/**cannot value-import asrc/**/*.tsmodule.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 navigablereproduced 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) andleaves the stock behaviour when the setting is disabled(Windows). Both are recorded asUnknownwith 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
allowScriptspolicy covers them, andpreparecounts as an install script for non-registry sources. A git dependency cannot be approved: anallowScriptsentry 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 theirdist/inprepareand commit no build output, so under npm 12 they installed asLICENSE,package.jsonandREADME.mdalone — measured with the community scanner’s own analysis command,npm ci --allow-git=all --allow-remote=all --ignore-scripts, which left nodist/index.jsand nodist/index.d.ts, breakingnpm run buildandtypechecktogether. Publishing to a scope we own and consuming throughnpm:aliases sidesteps the policy, because registry dependencies are exempt from it even when the published package still declaresprepare— upstream@codemirror/autocompletedoes, and installs clean under--strict-allow-scripts. The aliases preserve the original import specifiers, so no source file and neitheroverridesblock 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-registryresolvedentry at all, so the plugin also installs under npm 12’s bare defaults, whereallow-gitandallow-remoteboth 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.ymlthat publishes over OIDC trusted publishing with no token and is gated on the version being absent from the registry. Thepublishscript is renamed torelease, becausepublishis a real npm lifecycle hook that runs after upload and would have re-enterednpm publishon every success..gitignorenow un-ignores.github: its blanket.*rule matched the new workflow, sogit addskipped it silently, and the existing workflows survive only because they predate that rule. The workflow publishes with--ignore-scriptsand sets an emptyNODE_AUTH_TOKENfor its install step, becauseactions/setup-node’sregistry-urlwrites an.npmrccontaining a literal${NODE_AUTH_TOKEN}that npm ignores but yarn 1 aborts on, and this fork’sprepareshells out to yarn. - Fork:
~/Repos/autocomplete— published as@saberzero1/codemirror-autocomplete, with the same publish workflow. Afilesallowlist now drops.direnv,flake.nix,flake.lockand.envrcfrom the tarball, which.npmignorenever excluded: 14 files and 89.2 kB down to 9 files and 70.0 kB.
- Plugin:
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 with2>&1, and headless Neovim writes every message to stderr —msg_puts_printfinsrc/nvim/message.cends atfprintf(stderr, …)for everything except theinfo_messagebranch that--versionand--helptake — so any warning was concatenated into the value that^\d+$then rejected. The warning was Neovim’s own:.github/workflows/e2e.ymlsetsNVIM_LOG_FILE: /tmp/nvim-debug.logfor every job, Windows Neovim cannot create that path, solog_path_initfalls back and exports__NVIM_LOG_FILE_WANT, and onVimEnterruntime/lua/vim/_core/log.lua:check_log_filereportslog: "…" not accessible, logging to: "…". Linux and macOS can write/tmp/nvim-debug.logand never produced it. The intermittency is a 100 ms race and nothing else:check_log_filenotifies throughvim.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 theio.writelands; the warning still reaches the job log, where it is worth reading. Forced against the official 0.12.5 release tarball withNVIM_LOG_FILEpointed at a directory and the quit deferred past the timer — stdout14, stderr the warning — the old capture throws in realpwshand 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 reportsunknown, matching the shell script- Scripts:
scripts/install-neovim.ps1,scripts/install-neovim.sh
- Scripts:
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 thatnpm installwill 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/.npmrcinteractionCHANGELOG.mddocs/development/index.md: rewrote the dependency-URL warning as a dependency-spec warning and added a “Shipping a fork change” callouttest/flaky-inventory.md: recorded the root cause, the forced failure and the fix probe, plus the still-open WindowsNVIM_LOG_FILEdiagnostics gap the same investigation surfaced
[1.1.0] - 2026-09-25
Added
- Smart list continuation now continues blockquotes and callouts —
o/Oon> quote, a> [!NOTE]title or body line, a nested> > quote, an indented> quote, or a spaceless>quotepreviously produced a bare empty line, because the marker group inLIST_CONTINUATION_REwas mandatory and a quote with no list marker never matched. Measured against Obsidian’s own Enter key over 31 line shapes,odisagreed with Enter on seven of them, all of them quote-only lines; the plugin’s own Neovim backend already continued every one of those, since the stock Markdown ftplugin carriesn:>incommentsand the RPC projection preserves it. The marker group is now optional, and a match with no marker carries the indent and quote chain alone. The same change adds a padding group between the quote and the marker, so a list indented inside a quote (> - indented) keeps both rather than matching nothing — without it, making the marker optional would have continued that line as>and silently dropped the bullet. Behaviour with a marker present is unchanged, and the fenced-code-block exclusion still applies to quote-only lines, including fences written inside a blockquote. (#195)- Plugin:
src/actions/open-line.ts(LIST_CONTINUATION_RE,detectListContinuation)
- Plugin:
- The second half of #195 —
oon a parent item inserting at the child’s indent level — is not implemented. Obsidian’s own Enter, Neovim (both stock and under the RPC projection), and the bundled engine all insert at the parent’s level; it was measured on tab- and space-indented children in all three. It is genuine obsidian-outliner behaviour, guarded there by conditions this implementation has no line-local way to express (oonly, parent unfolded, cursor at end of line, indent and bullet copied from the first child), and it is not a shared outliner convention either — Logseq agrees with it, Workflowy’s documentation says Enter creates a node at the same level. Adopting it would makeodisagree with Enter inside one editor, which is the inconsistency the first half of the issue reports. It remains available from Lua.
Fixed
- The Neovim backend no longer rewrites tab indentation as spaces — with the backend connected,
oon a tab-indented list child produced a space-indented line, so every continuation in a tab-indented vault silently changed the document’s indentation convention. Vim rebuilds a continued line’s indent from its column count rather than copying the original bytes, and Neovim’s Markdown ftplugin setsexpandtabwithtabstop=4; nothing forwarded Obsidian’s own indent settings, so the mirror always re-emitted spaces. Obsidian’suseTabandtabSizenow become the mirrored buffer’sexpandtab/tabstop/shiftwidth/softtabstop, which also governs>>,<<and==. Hardcodingexpandtab=falseis not the fix and was measured to invert the bug — a space-indented vault then has its four-space child rewritten to a tab — so both directions are asserted. Found while building the three-way comparison for #195; the three remaining divergences there are inherent tocommentsand stay documented. (#195)- Plugin:
src/rpc/document-sync.ts(NeovimEditorOptions.indent,APPLY_EDITOR_OPTIONS_LUA),src/main.ts(newvaultIndentStyle())
- Plugin:
zz,ztandzbnow honourscrolloff— they reached the scroll position throughscrollDOM.scrollTopwithout dispatching a CodeMirror transaction, and the plugin’s margin enforcer is anupdateListenergated onupdate.selectionSet, so it never saw them;scrollToCursorhad noscrolloffterm of its own either. The margin was therefore ignored on every one of these commands, at the defaultscrolloffLinesof 5 as much as at a configured value. Measured against Neovim 0.12.5 (nvim --clean, 80x23,wrap,nosmoothscroll), it reaches them in two distinct ways.ztandzbhold the margin past the cursor line on any line — Neovim movestoplinefrom 61 to 56 forztatso=5, where the plugin left 0 rows — and both stop at the centred position once the margin no longer fits, which is whyzt,zbandzzall reporttopline50 atso=11and never move again atso=12orso=9999. Inside a line taller than the window, where Neovim scrolls byskipcolrather than bytopline, the margin instead applies to the cursor’s own display row:so=5givesskipcol400 andwinline18 against the plugin’s 23, andso=9999givesskipcol880 and a centredwinline12.zzis untouched on a line that fits, becausezzonly ever sets a whole-linetopline; and the margin stays out of reach at a tall line’s first and last display row, whereskipcolsaturates at 0 and atlineRows - winheight— which is why a cursor at the end of a very long wrapped line sits on the bottom row at everyscrolloff, exactly as reported in #183. Built-in vim mode is unaffected; it uses Obsidian’s bundled codemirror-vim. (#183)- Fork:
src/vim.js(scrollToCursor, newVim.setScrolloffSource) - Plugin:
src/vim/scrolloff.ts(newgetScrolloffLines),src/vim/bundled-vim.ts
- Fork:
- A folded heading no longer repeats its own title — folding
# Introductionrendered the heading and then a greyed-out copy of the same text beside it, so a long title cost roughly double the width when folded, and Obsidian’s own fold shows…there. A heading fold range starts at the end of the heading line, so the heading is never hidden and the title in the placeholder was always a duplicate of text still on screen. The placeholder now carries only what the visible line does not,— N lines, which is also what the nested-list folds in the report show. Both branches changed, the treesitter metadata one and the regex fallback, so the label no longer depends on whether the parser is ready. An empty ATX heading (#) also stops emitting the stray leading space its empty title used to produce. Fenced code, callout and frontmatter placeholders are untouched — the report is about the heading case, where the title is on screen in both editing modes. (#193)- Plugin:
src/fold/placeholder.ts
- Plugin:
- Undo no longer empties a note under the Neovim backend — this was data loss, not a display fault. The backend mirrors every note into one buffer created once in
NeovimDocumentSync.start()and renamed and reseeded on each activation, and undo history belongs to the buffer rather than to the note. Nothing reset it, so the oldest reachable entry was the buffer’s original empty state: pressinguon a freshly opened note emptied it, the line events mirrored that into CM6, and Obsidian saved the result. Measured on a two-note workspace,undotree().seq_lastwas 4 where a freshly activated note must report 0,ureplaced the note’s two lines with"", and the emptied buffer reached disk through the vault adapter. The reseed now runs atundolevels = -1, which discards the previous note’s history; reading the option yields the-123456“use the global value” sentinel when no buffer-local value is set, so saving and restoring it pins nothing — verified by confirming a latervim.go.undolevels = 500still reaches the buffer. The:help clear-undorecipe also worked but leftseq_lastat 2 and modified buffer content, so it was rejected. Ordinary within-note undo is unaffected and is asserted separately, because the obvious over-correction — clearing on every sync rather than on activation — would satisfy every other assertion and break editing- Plugin:
src/rpc/document-sync.ts(newRESEED_BUFFER_LUA)
- Plugin:
- The Neovim insert-completion popup no longer renders far from the cursor — it was anchored at
column × defaultCharacterWidthand(row + 1) × defaultLineHeight, terminal-grid arithmetic against a fixed 120x40 grid that bears no relation to proportional Markdown typography, wrapping, folds or the scroll offset. Measured 806.9px of horizontal drift from the cursor on a 45-character line. It now anchors atview.coordsAtPos(), the same choice the float bridge already makes forrelative = 'cursor'; grid cells remain only as the fallback for when CM6 cannot resolve a cursor rectangle. Anchoring alone left 399.5px, becausepopupmenu_showand the buffer’s line and cursor notifications share one RPC stream with no ordering guarantee, so the cursor read during the show predated the edit that produced the completion — instrumenting it showed the vertical anchor already exact and only the horizontal lagging. Deferring a frame did not help and reproduced399.531byte-for-byte, so the re-anchor is driven from a new document-sync observer that fires once CM6 holds Neovim’s text and cursor- Plugin:
src/rpc/popupmenu.ts(newreanchor()),src/rpc/document-sync.ts(newsetMirrorObserver()),src/rpc/neovim-connection.ts
- Plugin:
- The cursor shape now follows Neovim’s mode with the animated cursor off — which is the default, so with stock settings the cursor was permanently a block in every mode. The backend reported the mode correctly; nothing rendered from it. The bundled fork draws the visible cursor and resolves its shape from its own
cm.state.vim, and its keydown observer returns early whilesetKeyInterceptActiveis set, so that state never leaves normal; the animated canvas cursor was the only renderer consulting the external mode and it is opt-in. The fork gains asetExternalCursorMode()override that feeds the cursor renderer alone. Two constraints on it are load-bearing and were established by measurement: it must not writecm.state.vim, which the status bar and mode tracker share, and it must not redraw with a transaction, which disturbs the RPC composition input. A host-side attempt that violated both turnedrpc-ime.e2e.tsandrpc-lifecycle.e2e.tsfrom 5 and 13 passing into 3 and 2 failures and was reverted; those two specs are the gate for any further cursor work. Because an external mode change produces noViewUpdate, the fork keeps a registry of liveBlockCursorPlugininstances and refreshes them throughrequestMeasure- Fork:
src/block-cursor.ts(newsetExternalCursorMode,getExternalCursorMode,effectiveVimState,applyCaretColor,refreshExternalMode),src/index.ts - Plugin:
src/vim/external-mode.ts
- Fork:
- Neovim’s visual selection is now visible — with the backend connected, entering any visual mode highlighted nothing. The bridge sent a caret and only a caret:
vim_motions_cursorcarries(buf, row, col)andsyncCursor()dispatchedselection: { anchor }, so CM6’s selection was empty even while Neovim reported modev, measured as{from: 2, to: 2}with zero rendered selection elements. Charwise, linewise and blockwise selections now cross, with the head character included as Neovim includes it — a CM6 range excludes itsto, so the end carrying the cursor is extended by one code point rather than by one unit, which would split a surrogate pair. Blockwise becomes one range per row; its columns are byte offsets rather than display cells, so a block over rows of differing width is approximate in the same way as the rest of the bridge’s cell mapping. The extragetpos('v')round trip happens only in a visual or select mode, so ordinary typing is unaffected. The range is rendered as a decoration, not as CM6’s selection: mirroring it intoEditorSelectionrenders identically and makes Obsidian consume the Escape keydown before the delegation listener sees it, so visual mode could be entered and never left — measured as Neovim staying invacross<Esc>, and unaffected by clearing the DOM range, while disabling the mirroring restoredn- Plugin:
src/rpc/visual-selection.ts(new),src/rpc/document-sync.ts(syncSelection,visualRanges,dispatchCursor),src/rpc/key-delegation.ts(visualKind,syncVisual),src/main.ts,styles.css
- Plugin:
- A language server now attaches to every note, not just the first — the RPC backend mirrors every note into one buffer that is renamed in place, which broke a language server two ways.
vim.lsp.enable()attaches onFileTypeand skips a buffer whose'buftype'is already set, so once the first activation left the mirroracwriteno later activation could attach anything; and the rename sent nodidClose/didOpen, so a server that was attached went on attributing edits to the previously active note.activateDocument()now detaches any attached client before the rename and clears'buftype', restoringacwriteonce the content is in place. Measured against an in-process server: switching notes emitsdidClosefor the old path anddidOpenfor the new one, and avim.lsp.enable()registered mid-session attaches on the next activation, where a barefiletype detectstill does not — that is Neovim’s own rule, and it is what thebuftypeclear works around- Plugin:
src/rpc/document-sync.ts(newPREPARE_ACTIVATION_LUA)
- Plugin:
- Diagnostic signs, virtual lines and whole-line highlighting now cross the Neovim bridge —
ForwardedExtmarkcarriedhl_group,virt_textandpriorityand nothing else, so three ordinary persistent extmark fields that the redraw-time forwarding already saw rendered as nothing.virt_linesnow becomes a CM6 block widget,line_hl_groupa line decoration, andsign_text/sign_hl_groupreach the sign gutter. That gutter had to become multi-source first:setSignsEffectreplaced the whole set and only the mark gutter dispatched it, so backend signs and mark signs would each have erased the other; both sources are now held in the field and merged per line, behind asignMarkers()accessor so consumers do not depend on the shape. This is what diagnostic signs, diagnosticvirtual_lines, 0.12 code lens and gitsigns are expressed in.number_hl_groupandconcealremain dropped- Plugin:
src/rpc/companion.lua,src/rpc/decorations.ts(NeovimVirtualLinesWidget,signEntries),src/vim/sign-column.ts(newsetRpcSignsEffect,signMarkers),src/vim/statuscolumn.ts,styles.css
- Plugin:
- Neovim’s quickfix list is browsable from the picker —
getqflist()returns structured entries rather than screen cells, so it crosses the bridge without any grid reconstruction, and the picker already had a provider API to receive it. Aquickfixsource and aPicker: Quickfix listcommand surface whatever:grep,:vimgrep,vim.diagnostic.setqflist()or an LSP reference list populated, grouped by severity, with entries converted from Neovim’s absolute paths to vault-relative ones — an absolute path there produces an entry Obsidian cannot open. Entries outside the vault are listed but not navigable, because they have no vault path rather than a wrong one. The source yields nothing while the backend is disconnected. This does not add:cnext/:copento the bundled engine, which still has no quickfix model of its own- Plugin:
src/picker/sources/quickfix.ts(new),src/main.ts
- Plugin:
- Quickfix entries were unopenable on macOS —
vaultRelative()compared Neovim’s absolute path against Obsidian’s vault base with a plain prefix test, and macOS reaches/varthrough a firmlink to/private/var. Neovim resolves the path when it names a buffer while Obsidian’s adapter reports the base unresolved, so the prefix never matched: every entry rendered as an absolute path andonSelectsilently did nothing, because a null path is how the source represents “outside the vault”. Both ends are now normalised. Caught by the macOS CI shard rather than locally, since Linux has no such firmlink- Plugin:
src/picker/sources/quickfix.ts(stripPrivate,vaultRelativenow pure and taking a base path)
- Plugin:
:quickfixopens the quickfix picker, matching the ex command every other picker source already had. It carries no short form:Vim.defineExrequires the short form to be a prefix of the name,qfis not one, and Vim spells this:copenrather than:qfanyway- Plugin:
src/workspace/commands.ts
- Plugin:
Tests
- 6 e2e tests in
test/specs/vim-builtin/z-commands.e2e.tsfor thescrollofffix, every expected value taken from a headless Neovim 0.12.5 run rather than written by hand. Three cover the tall-lineskipcolregime — 5 rows below the cursor atso=5, a centred cursor row atso=9999, and a line that fits the viewport staying put at bothso=0andso=9999— and three coverzt/zbon an ordinary line, including the convergence property thatzt,zbandzzproduce one identical position oncesoexceeds half the window. Red first: the two tall-line tests failed at 1 row below the cursor against 5, and at 1.42 rows below against a required quarter-viewport. The two pre-existingzttests in the#143block were rewritten in the same units for the same reason:zt should place cursor line within top 15% of viewportasserted a flat 15% where the correct value is nowscrolloff / viewportRows, andzt should place cursor line near the top of viewport, not centerrequired thezt/zzgap to exceed 30% of the viewport, which the margin eats on a short window. Both encoded aztthat sat flush at row 0. They passed on this 54-row machine and on CI’s 37-row Linux runner and failed on its ~25-row macOS and ~27-row Windows runners, at0.202against0.15and161against173; the first was renamed tozt should place cursor line at the scrolloff margin, not the centrebecause its old name named the threshold. Both now assert in display rows against the measured viewport and the configuredscrolloff, and both were negative-controlled by makingztreturnzz’s position — the #143 defect itself — which failed them at22.10and21.32rows against tolerances of 2 and 1. Both of the new tall-line assertions are expressed against the measured viewport too, because the first versions were calibrated to one machine and failed CI on all three platforms:Math.round()on a value a sub-pixel below zero returns-0, andexpect(-0).toBe(0)failsObject.is, so the last-visible-row check now readsMath.abs(rows) < 0.5; and a hardcoded> 20rows described a 54-row viewport, where CI’s is ~37. Both were reproduced locally by shrinking the editor to 35.9 rows —-0and 17.44 against 20, the same two failures — and both pass at 35.9 and 54.2 rows after the change. Negative-controlled afterwards in two independent directions — restoring the pre-fixzt/zbposition cases failed all three of their tests (0 rows against 5, 0 rows against 5,blockTop0 against 632), and dropping the taller-than-viewport gate so the margin leaks onto a line that fits failed only the fits-the-viewport test (blockTop447 against 428), which is the assertion that gate exists for. Both controls left the other tests green, so each is targeted rather than a blanket break - 2 e2e tests in
test/specs/vim-builtin/z-commands.e2e.tsfor #183 — the follow-up comment reportszzcentring the wrapped line rather than the cursor, and leaving the cursor at the bottom of the screen on a very long line. Both were measured against Neovim 0.12.5 (nvim --clean, 80x23,wrap,scrolloff0,nosmoothscroll) and both are what Neovim does, so the tests pin them instead of changing them. The first runszzthree times on one 18-row line with the cursor at its first, middle, and last character and asserts an identicalscrollTop— Neovim reportstopline35 for all three, with onlywinlinemoving (7, 12, 17). The second puts the cursor on the last character of a line twice the viewport height and asserts it lands on the last visible row, matching Neovim’swinline23 of 23 atskipcol80/560/2960 for lines of 24/30/60 rows. Negative-controlled by rewriting thecentercase of the fork’sscrollToCursorto centrecharCoords(cursor).bottom: the first failed withscrollTop2518 against 2326 (8 display rows of drift from a cursor-column change alone), the second with 27 rows below the cursor instead of 0. The column-independence precondition is measured from the line’s own first row rather than the viewport, because a viewport-relative one is constant under that same sabotage and tripped before the assertion it guards. Existing coverage did not distinguish either case: the “centre the whole wrapped line” test already passed with the cursor fixed at the line end, and the tall-line test accepted the cursor anywhere in the last two rows - 1 e2e test in
test/specs/fold-providers.e2e.tsfor #193 — folds# Introductionwithzcand asserts the placeholder is exactly— 12 lines. Red first, atReceived: "Introduction — 12 lines". It replacesheading fold placeholder contains heading text, which pinned the duplication this removes, and which additionally checked nothing whenever no placeholder rendered: its assertion sat insideif (placeholders.length > 0 && placeholders[0] !== '…'), the same vacuity already removed from the callout test beside it. Intest/unit/fold/metadata.test.tsthe fourlabel()expectations moved to the new string, and the😀 écase now reads the title from the metadata entry rather than through the placeholder — it is the only post-tree.delete()probe in a test named for exactly that, and through the label it would no longer be distinguishable from the regex fallback, which never touches the tree. Negative-controlled in two independent directions: restoring the title in both placeholder branches failed 7 unit tests ('Title — 1 lines',' — 1 lines'and'😀 é — 1 lines'against'— 1 lines'), and dropping the extracted title failed the new probe atexpected '' to be '😀 é'while leaving the placeholder assertions green - 13 e2e tests in
test/specs/open-line-list.e2e.tsfor #195, every expected value taken from a measured Obsidian-core Enter result on the same line rather than written by hand. Red first: 10 of the 13 failed, each at the same shape — expected>, received the empty string;>against;> textagainsttext. The remaining three had no before-state and were negative-controlled by sabotage instead. Disabling the fenced-code-block guard failed both fence tests, at>and> -where an empty line was expected, while leaving the other ten green, so the control is targeted rather than a blanket break. The third, an undo test carried over in the shape already used for lists, could not be made to fail: splitting the singlereplaceRangeinto two adjacent calls did not break it, because CodeMirror coalesces them into one undo unit, and it had passed before the fix as well. It was rewritten to assert the post-odocument before undoing, which is the assertion red-first already proved falsifiable; the one-step-undo property itself remains unfalsified and is not claimed as covered - 2 e2e tests in
test/specs/rpc-editor-options.e2e.tsfor the indentation fix, asserting both directions because either alone passes under a hardcoded setting. Red first on the tab case, at" - X"against"\t- X", checked through the Neovim buffer and the CM6 mirror. The space case had no before-state and was negative-controlled by applying the naive fix — hardcodingexpandtab=falsefailed it at"\t- X"against" - X"while leaving the tab case green, so the control is targeted. The suite’sbeforeEachnow pins the vault’s indent settings so the two cases cannot leak into each other or into the existing scenarios - The 31-shape comparison behind those expected values was measured three ways — Obsidian’s Enter and the bundled engine’s
o/Oin-product, and the Neovim backend’so/Othrough a live RPC connection — and is recorded inKNOWN_LIMITATIONS.mdanddocs/features/quality-of-life.mdrather than committed as a spec - 5 e2e tests in
test/specs/rpc-undo-isolation.e2e.ts, written red-first against the defect. All five failed on the unfixed build with the values recorded inrpc-undo-isolation-negative-controls.md—seq_last4 against 0, and the note’s body replaced by""— and the disk assertion is what establishes severity rather than mere incorrectness, since the emptied buffer reachedTarget.mdthrough the vault adapter. The headless reproduction that preceded them understates the defect and is documented as such: drivingactivateDocument()’s call sequence from a Lua script reportsseq_last1, because changes without an interveningu_sync()join one undo block, where real keystrokes reach 4 - 1 e2e test in
test/specs/rpc-popupmenu.e2e.tscomparing insert-completion placement againstview.coordsAtPos()on a deliberately long proportional line, plus the existing insert case rewritten onto the same oracle. The previous assertion compared againstexpectedGridAnchor(), a spec-local helper that recomputed the production grid arithmetic, so it held for any anchor the implementation chose — the “expected value produced by calling the same code under test” shape — and it passed throughout the 806.9px error. The helper is deleted. Controls: 806.9px before the fix, 399.5px after anchoring but before the re-anchor, andExpected: 32, Received: 783.453when the old arithmetic is restored under the new assertions - 5 e2e tests in
test/specs/rpc-cursor-shape.e2e.tscovering block in normal and visual, the insert caret, the return to block, and the reset on disconnect. The reset is driven through disconnect rather than Escape becausesetExternalVimMode(null)is a real drivable path and covers the branch that would otherwise strand a bar cursor after the backend goes away. Insert mode asserts the absence of.cm-fat-cursorplus a non-transparent caret, becausemeasureCursor()computesshowCursor = !insertMode || overwrite || shape !== 'bar'and a bar shape therefore draws no element and reveals the native caret instead. Negative-controlled by cutting the forward insetExternalVimMode, which failed the insert and disconnect cases atExpected: not "rgba(0, 0, 0, 0)"while leaving the three block-cursor cases green - 18 e2e tests across
test/specs/rpc-lsp-capability.e2e.tsandtest/specs/rpc-native-capability.e2e.tsmeasuring how much of a native-LSP workflow already crosses the bridge, against an in-process LSP server registered intest/fixtures/nvim/lsp-probe.luaso the suite needs no language-server binary and no network. Server completion reaches the external popup menu, hover renders as a float, and diagnosticvirtual_textandunderlinerender as CM6 decorations carrying Neovim’s own highlight groups. Every extmark case carries a same-namespace, same-positionvirt_textpositive control, so no result can be satisfied by forwarding being broken outright — control 1 inrpc-capability-negative-controls.mddemonstrates that guard firing - 8 e2e tests in
test/specs/rpc-visual-selection.e2e.ts, red-first: all five original assertions failed atCM6 to receive a non-empty selection in visual mode. They assert the rendered selection text rather than coordinates —['alp']forward,['pha']backward, the whole line linewise, two lines acrossV j, and['lp', 'el']blockwise — so a correct range with a broken decoration still fails. Two of the eight exist to pin the design rather than the feature:keeps the CM6 selection a caret while visual mode is activeandleaves visual mode when Escape is pressedboth fail if the range is mirrored intoEditorSelection, which is the implementation that breaks Escape. Forward and backward charwise are separate cases because the inclusive-to-exclusive conversion extends a different end in each direction, and a single-direction test passes with the extension on the wrong end. Control: disablingvisualKind()leaves 1 passing, 7 failing, the survivor being the Escape case, which is pre-existing behaviour the spec protects - 3 e2e tests in
test/specs/rpc-lsp-capability.e2e.tsfor note switching, which moves the spec onto a two-file workspace. Red first:re-opens the LSP document when the mirrored note changesandattaches a late vim.lsp.enable() on the next note activationboth failed before the fix. The probe server intest/fixtures/nvim/lsp-probe.luanow recordsdidOpen/didCloseURIs so the lifecycle can be asserted by name rather than inferred from attachment.keeps a client attached to the mirror after switching notespasses both before and after by design — it is there to catch the fix over-detaching - The three
rpc-native-capability.e2e.tscases that pinnedvirt_lines,sign_textandline_hl_groupas not rendering are inverted to assert that they do, which is red-first against the previous behaviour. Each keeps its same-namespacevirt_textpositive control, so a pass still cannot come from forwarding being broken outright. The shared sign column’s own coverage —marks-gutter,statuscolumn, the threegutter-*specs andmarks-picker, 37 tests — was re-run to hold the multi-source change - 3 e2e tests in
test/specs/rpc-quickfix-picker.e2e.tsseeding a real quickfix list over RPC and reading the rendered picker rows. Controls inrpc-quickfix-picker-negative-controls.md: an emptyitems()fails two of the three, and returning Neovim’s absolute path instead of the vault-relative one fails only the path assertion, with the absolute paths shown. The empty-list case passes under the first sabotage by design, which is why it is not the only test - 6 unit tests in
test/unit/picker/quickfix-path.test.tscovering the vault-relative conversion on both spellings of a macOS firmlinked path, a path outside the vault, a sibling directory sharing a prefix, and an empty filename. Platform-independent by construction:vaultRelative()was made pure and takes a base path, so the macOS case is reachable from a Linux run. Control: removing the/privatenormalisation fails exactly the two firmlink cases and leaves the other four green. The e2e assertion inrpc-lsp-capability.e2e.tsthat compared the mirror buffer’s name to the vault path was over-strict for the same reason and now compares resolved paths; a non-existent path still resolves to itself, so the relative-path defect it guards against still fails it - 3 unit tests in
test/unit/ex-command-prefixes.test.tschecking every statically declareddefineEx(name, prefix, …)insrc/.Vim.defineExthrows when the short form is not a prefix of the name and nothing catches it, so the throw aborts the registration function and every ex command declared after the bad one is silently never registered. That shipped once during this work —defineEx('quickfix', 'qf', …)took:recent,:grepand:livegrepwith it, surfacing as eleven failing picker tests that named none of them. Control: reintroducing the bad prefix fails the scan withworkspace/commands.ts: 'qf' of 'quickfix'. A third assertion guards the scan itself, since a regex matching nothing would make the other two vacuous - 2 unit tests in
test/unit/docs-stray-strikethrough.test.tsscanning every published Markdown file for bare tildes that pair into accidental strikethrough. GitHub-Flavoured Markdown accepts a single tilde as a delimiter, so prose writing an approximation as~25is harmless alone and renders struck-through the moment a second bare tilde lands on the same line. Eight lines were affected, including an Unreleased entry reading “failed on its ~25-row macOS and ~27-row Windows runners”, which rendered “25-row macOS and ” with a line through it. Control: unescaping that entry fails the scan withCHANGELOG.md:53. A second assertion guards the file list, since scanning nothing would make the check vacuous
Documentation
CHANGELOG.mddocs/features/quality-of-life.md: blockquote and quote-indented-list continuation added to the supported list, the fenced-code-block exclusion stated, a Neovim-backend callout recording the four measured divergences, and the parent-level insertion behaviour written down with its rationaledocs/reference/keybindings.md: theo/Oquality-of-life row now names blockquotesKNOWN_LIMITATIONS.md: the RPC list-continuation paragraph now records the three measured divergences that are inherent tocomments— blockquoted list markers, checkboxes, and fenced-code exclusion — notes that blockquote continuation itself agrees, and records indentation style as a fourth projected setting with the measured reason the naive fix is wrongAGENTS.md: new “Parity target: Neovim, except where Obsidian is the sensible choice” section stating the default and the two classes that qualify for deviating — Obsidian-flavoured Markdown editing conventions, and Obsidian-specific syntax — with the requirement to measure and record each such decision;document-sync.tsin the file tree now names the projected editor optionsCONTRIBUTING.md: the samedocument-sync.tsdescription, kept in sync withAGENTS.mddocs/features/neovim-backend.md: the smart list continuation row now names blockquotes, the three thingscommentscannot express, and indentation styledocs/features/workspace-navigation.md: the fold placeholder sentence claimed the heading title was part of the label; replaced with the per-provider formats and why the heading case carries only a line countdocs/reference/keybindings.md: added the vertical scroll section (zz,z.,zt,z<CR>,zb,z-), which was undocumented, with the measured wrapped-line andscrolloffbehaviourKNOWN_LIMITATIONS.md: recorded that the firstzz/zt/zbpress from a distant scroll can land up to two display rows off, because CodeMirror estimates coordinates for content it has not renderedAGENTS.md: addedsetScrolloffSourceto the fork capability list, with the two regimes it reproduces and why it is a callback rather than adefineOptionvalue- Fork
DIFFERENCES.md: newscrolloffinzz/zt/zbsection carrying both measured Neovim tables, a newsetScrolloffSourceAPI section, and a cross-reference from the existing wrapped-lines section noting its measurements are all atscrolloff=0 docs/features/neovim-backend.md: the bridged list now namesvirt_lines,sign_textandline_hl_group, the visual selection and its decoration-not-selection rationale, and the per-activation LSP document lifecycle; the limitations list gains the quickfix picker and the two extmark fields that are still droppeddocs/features/ex-commands.md,docs/reference/keybindings.md::quickfixadded to the picker command tables, with no short form becauseVim.defineExrequires one to be a prefix of the nameREADME.md,docs/features/index.md: picker built-in source count 14 to 15, naming the Neovim quickfix sourceREADME.md,KNOWN_LIMITATIONS.md,AGENTS.md: re-certified latency figures, and the p50 gate described as a two-directional budget rather than a fork-faster orderingKNOWN_LIMITATIONS.md: three entries struck through as fixed — per-mode cursor shape,vim.lsp.enable()attachment, and the extmark field set — plus a new entry recording the measured coupling behind multi-leaf mirroring and one recording that the visual selection is a decoration rather than an Obsidian selection- Fork
DIFFERENCES.md: newsetExternalCursorModeAPI section covering the three call sites that must agree and the two constraints established by measurement, and a correction toresetCursorStatewhich no longer described everything it clears CHANGELOG.md,KNOWN_LIMITATIONS.md,docs/features/animated-cursor.md: eight lines where two bare tildes on one line rendered the text between them struck through now escape them; the change is to the escaping only, and no wording or measurement was altered
[1.0.1] - 2026-09-23
Fixed
- Which-key overlay no longer opens on a literal-argument leader key —
r<leader>, and any other command awaiting a literal<character>argument (f,t,m,q,"), opened the leader overlay when the leader key was used as that argument. The fork signalsvim-keypressonly after it has consumed the argument, so the key was indistinguishable from a standalone leader press; the overlay now carries the previous key’sexpectLiteralNextstate across the event and skips leader handling when the key was consumed as an argument. (#186)- Plugin:
src/ui/which-key.ts,src/types/vim-api.d.ts
- Plugin:
:obcommandno longer loses a charwise selection — a mapping such asvim.keymap.set("v", "<C-n>", ":obcommand templater-obsidian:create-new-note-from-template<CR>")ran the Obsidian command with no selection, so Templater and every other selection-dependent command saw nothing; the same command from the command palette, which never goes through the fork, saw it. The fork prefills'<,'>when:is pressed in visual mode, so the dispatcher received'<,'>obcommand …withselectionLine === selectionLineEndfor a selection inside one line — a case the #161 line-range restore skipped outright, while it widened a two-line charwise selection to both whole lines. The handler now rebuilds the range in document offsets from the'</'>marks andlastSelection, which surviveexitVisualModeand carry columns, so charwise, linewise, and blockwise selections each restore as themselves. A typed numeric or%range keeps expanding to whole lines. (#192)- Plugin:
src/workspace/commands.ts
- Plugin:
- Callouts no longer stay highlighted after a linewise yank — a callout the visual-line selection passed through kept a selection-coloured background forever, surviving the yank, further motions, and editing the block. Live Preview renders a callout the cursor is outside of as a block widget, which no CodeMirror mark decoration can reach, so linewise selection paints those widgets by toggling a class on their DOM element; the clearing loops skipped any element that was not
isConnected. That is exactly the element that needs clearing — the moment the selection head enters the callout, Obsidian swaps the widget for source lines and caches the detached element, so the skipped class came back with the element on re-attach, and the element had already been dropped from the plugin’s tracking set by then. Both clearing loops now remove the class unconditionally. (#190)- Plugin:
src/vim/linewise-widget-highlight.ts
- Plugin:
- Yank highlight now covers rendered blocks —
yGover a callout flashed every line around it and left the callout itself untouched, and the same held for embedded notes, images, and tables. A block that Live Preview renders is a CodeMirror block widget, and no mark decoration can reach one:ContentBuilder.pointdrops the active marks when it emits a block point. Those blocks are painted through their DOM element instead, on the same timer as the decorations, and a block is painted when the yank touches any line it stands in for — it is opaque, so there is no partial state to show. The painting is derived from the highlight state on every view update rather than applied once, because a linewise yank leaves the CodeMirror selection spanning the range, which makes Live Preview reveal the blocks it touches as source lines; they only return as widgets a transaction later, when the cursor collapses. The fade mode animatesbackground-colorrather than the decorations’opacity, which on a block would take its rendered content down with it. (#190)- Plugin:
src/vim/yank-highlight.ts,src/vim/block-widgets.ts(new — shared withlinewise-widget-highlight.ts) - Styles:
styles.css
- Plugin:
Kon a wikilink now opens a page preview that stays open — the preview either did nothing at all or flashed up for well under a second before vanishing, which made keyword lookup unusable. The action synthesised a barenew MouseEvent('mouseover'), and Obsidian’s page preview reads two things off that event. Every hover-link source can be configured to require Ctrl/Cmd, and a modifier-less event does not open the preview — it only arms a document listener that waits for a real Mod keydown, which is whyKlooked inert until Cmd was pressed afterwards. Separately, page preview records the event’s client coordinates as the pointer position, and the popover then re-checks every 500ms thatelementFromPoint()there still resolves inside its target element, hiding itself when it does not; an event at (0, 0) resolves to the top-left corner of the window, so the preview hid itself about a second after showing. The event now carries the Mod flag — a deliberateKpress is the explicit request that modifier exists to confirm — and the cursor’s own viewport coordinates fromcoordsAtPos, which also anchors the popover to the link rather than to the corner. (#188)- Plugin:
src/workspace/navigation.ts
- Plugin:
Tests
- 5 e2e tests in
test/specs/yank-highlight-widgets.e2e.tsfor #190 —yGpainting a callout and an embedded note, release when the highlight expires, a yank that stops short of the callout leaving it alone, and fade mode keeping the callout’s computed opacity at1. Each was negative-controlled against a deliberate break: dropping the coverage filter painted the untouched callout (0 → 1), a no-op unpaint left it lit past expiry (0 → 1), reusing the decorations’ opacity fade dropped the callout to opacity0.646399 - 3 e2e tests in
test/specs/linewise-widget-selection-issue190.e2e.tsfor #190 — two drive the stale highlight through the transition that causes it (selection head enters the callout, widget detaches) and assert the re-attached widget is clean after a yank and after<Esc>; the third selects past the callout without ever entering it and asserts the widget is highlighted, because “nothing is highlighted” would otherwise pass for a build that highlights nothing at all. The suite waits on the widget actually rendering, so it cannot pass on an editor that never produced one. Each test strips the class on entry: Obsidian’s widget cache outliveseditor.setValue(), so without it the leak fails the following test instead of the one that caused it - 4 e2e tests in
test/specs/obcommand-visual-mode.e2e.tsfor #192 — two drive a real<C-n>mapping and readeditor.getSelection()from inside the dispatched command (single-line and two-line charwise), one runseditor:toggle-boldover a selection straddling two words, one holds:1,2obcommandto whole lines while a charwise selection sits in the marks. The straddle is deliberate: the first version of that test selected a whole word and passed against the unfixed build, becauseeditor:toggle-boldfalls back to the word under the cursor when it sees no selection - 2 e2e tests in
test/specs/keyword-lookup.e2e.tsfor #188 — one forces every registered hover-link source to require Ctrl/Cmd, reproducing the reporter’s configuration, and assertsKalone still opens the preview; the other asserts the preview is still on screen 2.5s later, past the 500ms watchdog and the 300ms hide timer it starts. Both assert the popover’s text containsTarget.md’s body, so a popover from any other surface cannot satisfy them. The first version of the require-Ctrl/Cmd test omitted the configuration step and passed against the unfixed build, because thepreviewsource does not require the modifier by default. Both then failed red (presentfalse in each case), and each half of the fix was controlled in isolation against the finished code: droppingctrlKey/metaKeyfailed only the first test, and pinning the coordinates back to (0, 0) failed only the second
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: extended the ex command path entry in the Obsidian command passthrough section with the charwise restoreCONTRIBUTING.md: addedsrc/vim/block-widgets.tsto the codebase structure treedocs/features/quality-of-life.md: recorded that yank highlight covers rendered blocks wholedocs/features/quality-of-life.md: recorded thatKopens the page preview regardless of the Page preview core plugin’s per-source Ctrl/Cmd requirement, and that the preview stays open
[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
- Plugin:
- 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_eventnotifications 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
- Plugin:
- Dedicated Neovim configuration setting —
neovimConfigPathoptionally points the backend at a minimal Obsidian-specificinit.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 toruntimepath, 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
- Plugin:
- Neovim RPC key delegation — while connected, a lifecycle-owned capture handler forwards every non-composition editor key with
nvim_input, suppresses the bundled fork throughsetKeyInterceptActive, 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
- Plugin:
- 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
.luasource file embedded intomain.jsby 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
- Plugin:
- Neovim RPC write and read routing — the named mirror is now buffer-locally
buftype=acwrite. A buffer-scopedBufWriteCmdroutes:wand:w!through Obsidian’seditor:save-filecommand and clears Neovim’s dirty flag; a buffer-scopedBufReadCmdmakes:eand: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
- Plugin:
- Neovim RPC Obsidian feature bridge (M4a) — generates selected mappings and user commands from
VimRegistration, dispatches every host action through oneobsidian_actionnotification channel, and restores the files picker, Oil, Harpoon slot 1, vertical split, next-heading navigation, and lowercase:sidebarwhile 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
- Plugin:
- 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
- Plugin:
- 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
- Plugin:
- 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
- Plugin:
- 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 nativeundotree()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
- Plugin:
- 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 nativegq/gwand 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
- Plugin:
- 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/atsupplies tag matching with fork-compatible count handling. Explicit visual ranges keep every operator bounded, and teardown removes every mapping.- Plugin:
src/rpc/companion.lua
- Plugin:
- 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
- Plugin:
- Neovim RPC IME composition bridge (M6b) — a cursor-positioned input target outside CM6’s
contentDOMowns native composition and keeps preedit out of Neovim. Commits enter throughnvim_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
- Plugin:
- 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
- Plugin:
- Neovim RPC external command line (M8b) — renders
cmdline_show, byte-correctcmdline_pos,cmdline_special_char, and level-scopedcmdline_hideevents in a themed editor overlay. First characters, prompts, confirm choices, and nested levels make typed commands, searches,vim.ui.input(), and genericvim.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
- Plugin:
- 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
- Plugin:
- Neovim RPC status-bar mode ownership (M8d) — routes
msg_showmodeinto 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
- Plugin:
- The table-nav overlay was never broken under the Neovim backend, and the documentation saying otherwise is corrected — no code changed.
canActivate()gates onforkAvailable, 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:lmoved 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
- Docs:
- 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:iCis the code fence andiothe callout, not the other way round. Leader prefixes are also registered as<Nop>mappings carrying only a group label, so which-key.nvim renders+findand+harpoonmenus 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
- Plugin:
- 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 thehintModeleader mapping are ordinary registrations the bridge already installs. Flash and EasyMotion share that samecaptureKeyspath, so their triggers are one allowlist entry away too; they are deliberately left out becausesand<leader><leader>already mean something in Neovim. Separately, every generated mapping and command now carries a readabledescprefixedVim Motions:rather thanvim-motions-rpc:<id>, so:map,:commandand 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
- Plugin:
- Cursor shapes and input-method switching follow Neovim’s mode — both read the bundled fork’s own state or its
vim-mode-changeevent, 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.tsis 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 throughresolveVimModeWithExternal, 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, sinceonModeonly 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
- Plugin:
- 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
datefrom the bundledglobal.jsonthrough a real LuaSnip, which produced the resolved date rather than the literal variables. Set up Neovim now installs LuaSnip, writes the bundled snippets out ofmain.jstovim-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 reactivef()/d()nodes call into Obsidian’s vault.- Plugin:
src/rpc/config-export.ts,src/main.ts
- Plugin:
- 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
- Plugin:
- 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.packinto 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.addandvim.pack.updateare both non-interactive headless, the latter needingforce = trueto skip its review buffer.- Plugin:
src/rpc/config-export.ts,src/rpc/neovim-connection.ts,src/settings.ts,src/main.ts,styles.css
- Plugin:
--cleansilently disabled Neovim package activation — with a configured Neovim configuration path the backend spawns under--clean, which strips the user packpath.vim.packthen clones a plugin to disk and never puts it on the runtimepath, sorequirestill fails: an install that reports success and does nothing. Measured directly —rtp_has_plugin=falseunder--cleanagainsttruewithout it. The spawn now appends Neovim’s ownstdpath('data')/siteback, which restores packages without reinstating the wrapper-injected runtimepath--cleanexists to exclude; a configuredinit.luastill loads.- Plugin:
src/rpc/neovim-connection.ts
- Plugin:
- 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, andmini.operators, alongside the leader key andtextwidth. It writeslua/vim_motions.luabeside the user’s Neovim configuration and does nothing until they addrequire('vim_motions')themselves; a Copy button serves the pick-and-choose case. The path must be underlua/to be requireable — a file besideinit.luais not, measured — and it is resolved by asking Neovim forstdpath('config')rather than reconstructing it fromXDG_CONFIG_HOMEandNVIM_APPNAME, which M2a measured as unreliable against wrapper installs. Every block is wrapped inpcall(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
- Plugin:
- 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/Ocontinues-,*, and+bullets, which the stock Markdown ftplugin does not do (formatoptions=jtcqlnomitsoandr). It cannot simply drop the ftplugin’sfcomment flag: that flag serves two behaviours at once — it givesgqits hanging indent while stoppingorepeating the marker — and removing it madegqre-bullet every wrapped line, whichrpc-structural-navcaught. The continuation form is swapped in only for the duration of ano/Oinsert, through anexprmapping returning the key so count, undo, and dot-repeat stay native; numbered lists remain out of reach becausecommentscannot increment a counter. Yank highlight is reported by aTextYankPostnotification and drawn by the host’s own renderer, sofadeworks as well assolid. 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
- Plugin:
- Arbitrary Obsidian commands are reachable from Neovim —
:oband:obcommandjoin the feature bridge, so:ob theme:use-darkexecutes 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
- Plugin:
- Neovim backend crash breadcrumb — a persisted
neovimToggleInFlightmarker 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’sUtilityProcessis unavailable to plugins. Scoped to real transitions — a reload that neither connects nor disconnects writes nothing.- Plugin:
src/main.ts,src/settings.ts
- Plugin:
treesitter-handle-leakast-grep rule — flags aTreeCursorallocated with.walk()whose enclosing scope never callsdelete()on it.web-tree-sitterregisters every handle with aFinalizationRegistryanddelete()unregisters it first, so a dropped cursor is not a leak — it is a deferred free of its tree’s pointer (the registry holdsthis.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,getAllNodesOfTypeat88ec375~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/varbindings never freed in scope, on a discardednode.walk();, and on a reassignment in a scope with nodelete()at all, with no false positives across four correctly-freed shapes. The discarded-value arm uses a direct-childhasdeliberately — withstopBy: endit also matchedcursor = 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.TreeandQueryhandles are out of scope, sinceparse()andnew QueryWrapper()legitimately transfer ownership to a cache or caller; their owners are asserted by unit tests instead.
- Coverage was measured against a probe of every form rather than assumed: it fires on
Changed
- Minimum Obsidian version raised from 1.7.2 to 1.8.7 — RPC message severity styling applies its class to
Notice.messageEl, whichnoticeElpredates;messageElis available from 1.8.7. Vaults on 1.7.2 through 1.8.6 will not receive this release.README.mdand the installation guide state the new floor. - The bundled vim fork’s own type declarations are used —
src/types/codemirror-vim.d.tsdeclaredVimasRecord<string, unknown>, shadowing the 166 members the fork’s shipped.d.tsexports and reducing everyVim.*access across the 19 files that import it tounknown. All 15 fork-specific exports it hand-declared (foldopenAnnotation,setCursorSuppressed,setKeyInterceptActive, …) were already shipped, so deleting it reports zero errors insrc/andtest/while restoring real checking. The casts that erasure forced are gone:escape-guard.tsnow callsVim.setIdleEscapeCallbackdirectly, and the demand harness callsVim.map/unmap/getOperatorfuncwithout a hand-written view.getBundledVimApi()keeps its conversion — the plugin’s ownVimApiinterface genuinely does not overlap the fork’s shape.- Plugin:
src/types/codemirror-vim.d.ts(removed),src/vim/escape-guard.ts
- Plugin:
- 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 --versionand 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
- CI:
- 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=exprfold using the same start-of-document delimiter rule as CodeMirror, and post-key cursor synchronization resolvesfoldclosed()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
- Plugin:
- 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
- Plugin:
Fixed
]hand[hwaited a second before moving — M5a added]h1–]h6and[h1–[h6as descriptive aliases for the level-specific heading motions, which the plugin already binds to]1–]6and[1–[6. The aliases made]ha prefix of six longer mappings, so every press hit the fork’s prefix-ambiguity deferral and waited outoperatorshadowtimeout(1000 ms) before moving. The redundant aliases are gone; the numeric bindings are unchanged.- Plugin:
src/motions/register.ts,src/rpc/companion.lua
- Plugin:
- 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
- Plugin:
- A rapid Vim-mode toggle applied only its first half —
disableVimandenableVimcleared their in-progress flag from a 500 ms timer armed infinally, 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
- Plugin:
- Heading motions became no-ops before the parser settled —
]h/[hand 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
- Plugin:
- 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
- Plugin:
- RPC fold expression no longer rescans the complete document for every queried line — the window-local Markdown
foldexprpreviously 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 Neovimchangedtickand reuses it for the remaining fold queries.- Plugin:
src/rpc/frontmatter-fold.ts
- Plugin:
- 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
- Plugin:
- RPC notes now use a real Markdown buffer instead of the dashboard buffer — M2a previously wrote into Neovim’s intro buffer by forcing its
modifiableoption. With alpha-nvim loaded that buffer remainedbuftype=nofileandfiletype=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 theacwritemirror described above.- Plugin:
src/rpc/document-sync.ts,src/rpc/msgpack-rpc.ts
- Plugin:
- 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
- Tests:
- 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
- Plugin:
- Harpoon same-leaf navigation preserves stored cursor positions — changing the file in one leaf fired
active-leaf-changeafter 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
- Plugin:
- Oil sort cycling changes the rendered order — directory rendering always read the configured default sort value, so
gsadvanced 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
- Plugin:
- 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
- Plugin:
- 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
- Plugin:
g-andg+navigate the undo tree again — both actions capturedthis.undoTreein a local at registration time, butactivateUndoTreeForFile()swaps that field per note, so the capture was orphaned on the first file activation while edits kept recording into the live tree throughbuildUndoTreeExtension().g-walked the empty orphan and returned at itsif (!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
- Plugin:
- Renderer crash on Neovim RPC disconnect —
disconnectChild()sentnvim_command qa!and then awaited the child’s'close'event, with aSIGKILLfallback, 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 defersSIGTERMwith aSIGKILLescalation to a later turn; nothing is awaited and both timers re-checkexitCode/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
- Plugin:
- Renderer crash from retained tree-sitter nodes —
getAllNodesOfType()collectedNodeobjects during a tree walk and read them afterwards, andheadingLevelFromNode()callednode.child()on those retained nodes. ANodeis 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 atbase + garbage, which segfaults the renderer with no JavaScript frame. Structural heading motions (]h,[h, level variants) now walk with aTreeCursorand 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
- Plugin:
vim.treesitter.query.parse()leaked its compiled query — every call built aQueryWrapperthat no cache owned, soNamedQueries.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, throughrunCleanupsso one failing disposer cannot skip the rest.- Plugin:
src/lua/treesitter/query-api.ts
- Plugin:
- Query capture nodes carried no tree reference —
Query:iter_captures()andQuery:iter_matches()pushed nodes without a_treefield, sonode:tree()returnedniland 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 existingiter_childrenshape and releasing the reference when the iterator is exhausted.- Plugin:
src/lua/treesitter/query-api.ts
- Plugin:
- 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-blockingSIGTERMthat 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, andSIGTERMlets Neovim exit on its own terms whereqa!did not.- Plugin:
src/rpc/neovim-connection.ts
- Plugin:
- 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-sitterTreeCursor:getAllNodesOfTypeallocated one per structural motion and never deleted it, leaving it in the library’sFinalizationRegistry, 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
- Docs:
- Two trees handed to Lua had no owner —
vim.treesitter.get_string_parser()andTSTree: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 unpredictableTSNodestaleness, 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
- Plugin:
- The Lua treesitter parser caches outlived the Lua state —
parserCacheandltreeCachewere module-level, solua_closeleft 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 throughregisterStateCleanup, deleting cached trees and destroying cachedLanguageTrees.- Plugin:
src/lua/treesitter/api.ts
- Plugin:
Tests
test/now passes full strict type-checking —typecheck:testsrunstsc -p tsconfig.test.jsonunfiltered 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 againstsrc/were configuration gaps rather than defects and are resolved by including the existing ambient declarations; nosrc/code changed. The pass surfaced four defects no other gate could see: a vacuousexpect(result.flash).toBe(DEFAULT_SETTINGS.flash)comparingundefinedtoundefinedagainst a setting that does not exist, arunCleanups()call missing its requiredcontextargument, avimHandleKeys(string[])call whose thrownTypeErrorwas swallowed by the diagnostic’s owncatch, and aProbeinterface 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
- Tests:
- The
vim.treesittere2e suite now tests something — all 12 of its assertions readgetLuaConfigError(), which returnedplugin.luaLoadResult.error. No such member exists on the plugin, so the helper returnednullunconditionally and the whole suite passed against a Lua body ofthis is not valid lua @@@ ###; thevim.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
- Tests:
- Spec shape guards —
test/unit/spec-shape-guards.test.tscloses the class strict typing cannot: a hand-writtenRecord<string, T>plugin handle is an assertion about the plugin, so an invented member compiles and silently readsundefined. It cross-checks every declared plugin member intest/— 97 spec files declare one — against the class insrc/main.ts, and fails anyexecuteObsidiancallback 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
- Tests:
- Root configuration files are type-checked —
typecheck:configadds a seventh blocking gate overwdio.conf.mts,eslint.config.mts,vitest.config.ts, and the.mjsbuild, release and CI-reporting scripts, none of which sat inside either existing tsconfig include.wdio.conf.mtsreported 22 errors on first measurement, including in theafterTestcleanup hook whose failure surfaces as cross-test flakiness rather than a red test. It needsmoduleResolution: nodenextand type-only imports ofwebdriverioandwdio-obsidian-service, because adeclare globalaugmentation only loads if its package is imported. The.mjshalf reported 11 more:esbuild.config.mjswas clean, butreport-e2e-failures.mjs— the script that reports CI failures — carried an implicitany[], four uncheckedstring | undefinedarguments and an unguardedMap.get()dereference, andversion-bump.mjsreadprocess.env.npm_package_versionwithout a guard, so running it outsidenpm versionwrote a version-lessmanifest.jsonand a literal"undefined"key intoversions.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
- Tests:
- M7 latency certification harness —
test/specs/rpc-latency.e2e.tsmeasures 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 inrpc-latency-negative-controls.md. - RPC prerequisite coverage — all 13 RPC specs use
test/specs/rpc-prerequisites.tsto 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
SIGKILLand reconnect, plugin unload, missing binaries, and the API-level floor. PID liveness is checked directly, teardown/version-floor sabotages are recorded intest/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.tschecks 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 reproducedTarget.mdasA original\nA second lineon first activation andrpc-A original\nA second lineafter the later switch, including on disk. - RPC key delegation acceptance coverage —
test/specs/rpc-keys.e2e.tsdrives 210 insert/operator/visual/count/dot-repeat/undo-redo/macro sequences containing ASCII, astral, combining, and CJK text through the editor DOM, comparesciwfoo<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 producedxxseedwithout fork interception,xxiseedwithoutpreventDefault, CM column 0 instead of 6 without cursor sync, and buffer/register divergence after a bridged-onlyx. - RPC frontmatter acceptance coverage — six M2c scenarios in
test/specs/rpc-keys.e2e.tscover visible-mode connection, repeated upward motion, frontmatter-preservingddwith an independent vault read, guardedgg, Source-mode navigation, and API edits inside the fold.test/specs/rpc-keys-negative-controls.mdrecords the no-fold, no-guard, and Source-fold sabotages and exact cursor/document outcomes. - RPC decoration acceptance coverage —
test/specs/rpc-decorations.e2e.tscompares 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.tsverifies 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 Neovimmodifiedflag.test/specs/rpc-write-routing-negative-controls.mdrecords the four required sabotages and observed values. - RPC Obsidian bridge acceptance coverage —
test/specs/rpc-obsidian-bridge.e2e.tscovers seven representative host scenarios plus teardown-aware refresh.test/specs/rpc-obsidian-bridge-negative-controls.mdrecords isolated missing-map, no-op-dispatch, unguarded-abbreviation, and skipped-teardown failures. - M4b registry and workspace bridge coverage —
test/unit/vim-registration-inventory.test.tsguards 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:Pickersource 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.tsdrives 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’ingoilHelpfailed only its scenario. - M4b Batch 5 folding and undo-tree coverage —
test/specs/rpc-folds-undo.e2e.tscovers 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.tsruns 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 inrpc-structural-nav-negative-controls.md. - M5b Markdown text-object coverage —
test/specs/rpc-text-objects.e2e.tsruns 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 inrpc-text-objects-negative-controls.md. - M6a floating-window coverage —
test/specs/rpc-floats.e2e.tscompares 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.mdrecords missing-forwarding, one-cell offset, ignored-z-index, and stale-close failures. - M6b IME composition coverage —
test/specs/rpc-ime.e2e.tsdrives 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.mdrecords CM6-only commit, buffer-API commit, and composing-key forwarding failures. - M8a external-UI message coverage —
test/specs/rpc-messages.e2e.tscovers 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.mdrecords missing-dispatch, noisy-kind, and removed-dedup failures with observed counts and values. - M8b external command-line coverage —
test/specs/rpc-cmdline.e2e.tsdrives all 11 command-line, caret, prefix, prompt, selection, cancellation, nesting, and bundled-fork-isolation scenarios through real editor key events.rpc-cmdline-negative-controls.mdrecords 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.tsdrives 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.mdrecords ignored-selection, wrong-anchor, and suppressed-mode-handler failures with observed counts and values. - The
treesitter-handle-leakrule’s own behaviour is locked by a test —test/unit/treesitter-handle-leak-rule.test.tsruns the rule against nine fixtures and asserts exactly which fire: all five leak shapes, none of the four correctly-freed ones, plus the shippedgetAllNodesOfTypedefect. Negative-controlled by restoring thestopBy: endthat 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.tsasserts that aget_string_parser()tree and aTSTree: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.tsadds 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 treeanditer_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 timeg-was broken.test/specs/undo-tree.e2e.tsnow 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
pluginAutoFetchboundary 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.tsasserts the complete spawn argv, so any addedruntimepathentry fails it, and separately asserts that no file undersrc/rpc/imports the fetch or store modules or names their on-disk paths, which it reads fromplugin-store.tsrather than restating. Negative-controlled twice: prependinglua/.stagingto the runtimepath failed three of the five assertions, reporting a 7-element argv against the expected 5; adding aplugin-storeimport todecorations.tsfailed exactly one, reporting[ 'decorations.ts -> plugin-store' ]against[].- Tests:
test/unit/rpc/plugin-autofetch-boundary.test.ts,src/rpc/neovim-connection.ts
- Tests:
- Unit tests can now import any RPC module — anything reaching
src/rpc/companion.luafailed to load under Vitest, which parsed the Lua as JavaScript, so no test undertest/unit/rpc/could importneovim-connection.tsat all. AluaTextPlugin()mirrors esbuild’s'.lua': 'text'loader, alongside the existing.wasmprecedent.- Tests:
test/helpers.ts,vitest.config.ts
- Tests:
- Table navigation under the Neovim backend is covered —
test/specs/rpc-table-nav.e2e.tsasserts the overlay activates while RPC is connected, thatlmoves 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 forcingcanActivate()false, which reportedentered: false, navigated: falseagainsttrue, true.- Tests:
test/specs/rpc-table-nav.e2e.ts
- Tests:
- Companion-label and leader-group coverage — six unit cases cover
leaderGroupPrefixesandleaderGroupLabel, 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 atleader.lengthinstead ofleader.length + 1failed 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
- Tests:
- Hint-mode and bridged-label coverage —
test/unit/rpc/bridge-action-label.test.tscovers the label derivation across 13 names; two scenarios inrpc-obsidian-bridge.e2e.tsactivate hint mode from Neovim and assert the buffer is byte-identical afterwards, and that no generateddescleaks 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: removinghintactivatefrom the allowlist failed only the hint scenario; revertingDESC_PREFIXto the old id form failed three, including the refresh-inventory check. The refresh check now identifies leader mappings by their leader prefix rather than bydesc, 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
- Tests:
- External mode coverage —
test/unit/vim/external-mode.test.tscovers the Neovimmode()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: testingnoafternfailed 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
- Tests:
- 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.jsonand 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
- Plugin:
- 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
- Tests:
- Snippet expansion is covered end to end under the Neovim backend —
LuaSnipjoinstest/fixtures/test-plugins.jsonpinned at0abc8f39, which makes itrequire-able from a live Neovim through the sameruntimepathappend that already servesflash.nvimto 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
- Tests:
- Consent-modal and auto-regeneration coverage — three scenarios in
rpc-config-export.e2e.tsdrive 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
- Tests:
- 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 thevim.keymap.setcall 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
- Tests:
- Package-activation and install-list coverage —
rpc-config-export.e2e.tsasserts the real spawn leaves Neovim able to activate a package, since the--cleanfailure 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
- Tests:
- Configuration-export coverage —
test/unit/rpc/config-export.test.tsholds generation, the sentinel, the fingerprint and the module probe across 17 cases;test/specs/rpc-config-export.e2e.tsproves 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 thepcallguard failed two generation cases; making the fingerprint constant failed staleness; and dropping thelua/segment failed three e2e cases. The fourth control initially did not fire at all, becauseresolveGeneratedConfigPathhas 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
- Tests:
- Editor-option projection coverage —
test/specs/rpc-editor-options.e2e.tsdrives five scenarios through real editor keydowns rather thannvim_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’sfflag producedhelloagainst- hello; removing theo/Omappings produced the same; suppressing the yank notification failed only the highlight scenario; and dropping the reseed call producedhellowith no marker while the other four passed. A fourth control was not needed —rpc-structural-navalready failed on the first implementation, reportinggqoutput re-bulleted as- to wrap with a hanging indentinstead of the hanging-indent form.- Tests:
test/specs/rpc-editor-options.e2e.ts
- Tests:
- Generic Obsidian-command coverage —
test/specs/rpc-obsidian-bridge.e2e.tsruns:ob workspace:toggle-pinthrough 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 withobInstalled: falseagainsttrue, and neuteringexecuteCommandfailed it again at the pin wait while the guard scenario still passed. The bridge-installed probe is scoped by thevim-motions-rpc:marker because matching every Neovim user command picked up the developer config’sDapStepInto, which lowercases to containpin.- Tests:
test/specs/rpc-obsidian-bridge.e2e.ts
- Tests:
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:acwritesave 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, andtextwidthwiring.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: LuaTSNodehandles become stale after a re-parse, and lifetime management is declined rather than deferred. Also records that dropping thedelete()calls is not a safe alternative, becauseweb-tree-sitter’s ownFinalizationRegistrystill 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 retainingNodes and added the missingcursor.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 leakedTreeCursorstays inweb-tree-sitter’sFinalizationRegistryholding 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-repodelete()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, whilescreenpos()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.luastill loads, but avim.keymap.setthere targets the bundled fork, which key delegation stands down, so editor mappings never fire and their equivalents belong in the user’sinit.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’svim-keypress/vim-command-done, yank highlight tovim-yank, input-method switching tovim-mode-change, and the animated cursor resolves its per-mode shape fromadapter.state.vim, so the shape stays on normal while Neovim is in insert. Measured on 0.12.5 rather than assumed:gris unmapped, andoon- item oneyieldshellobecause the stock Markdown ftplugin setsformatoptions=jtcqln. Corrects a claim added earlier in this same section, which listed snippets, which-key, and the animated cursor among features that still apply —Tabis forwarded to Neovim before CM6’s snippet keymap sees it.AGENTS.md: re-measures thees2021build-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 raisingminAppVersiondoes not raise it, while iOS went 13 → 14.5 → 15. Ates2022esbuild emits 8static {}blocks wherees2021emits 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#privatemethods are dropped as unevidenced —#privateoccurs 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 thetagsfrontmatter every other page carries.KNOWN_LIMITATIONS.md: the async-Lua row and therequire()status both still said “deferred” long aftersrc/lua/coroutine-runner.tsandsrc/lua/module-snapshot.tsshipped.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: theguicursorexample 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 LuaTextYankPostautocmd 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: recordssrc/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 aTextYankPostnotification 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 asetup()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, andnvim-surround’s defaults omitdsf/csfand 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 explorercommand — reveals the folder containing the activeinit.lua/.obsidian.vimrcin the OS file manager, with the configuration file itself selected. That folder is the onerequire()searches for alua/directory, so it is the folder to manage modules from. Obsidian already exposes this as the unofficial-but-typedApp.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)
- Plugin:
cursorlineoptaccepts Neovim’s full grammar, includingscreenline— the option was a three-value enum (number/line/both). It now parses the real comma-separated list overline,screenline,numberandboth, in any order, withbothas Neovim’s alias forline,numberand theline+screenlinecombination 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"storesboth).screenlinehighlights only the cursor’s display row of a wrapped line, drawn as a measuredRectangleMarkerinside a CodeMirrorlayer()— aDecoration.linespans 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, andmigrateCursorlineoptSettingspins every existing vault to the previousnumber, so no installed configuration changes appearance. Stored values need no rewriting —number,lineandbothwere already valid Neovim spellings. The one undetectable case is a vault that has never writtendata.json, recorded inKNOWN_LIMITATIONS.md. - Plugin:
src/vim/cursorline-option.ts(new — grammar, normalization, canonical states),src/vim/cursorline.ts(screenlinelayer),src/settings.ts(type, default, both settings implementations),src/settings-migration.ts,src/main.ts,src/vimrc/loader.ts(normalizehook for string options),src/vim/options.ts,styles.css
- The default becomes Neovim’s
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
deletebuflineare implemented. Interior-byte cursor writes deliberately normalize (D4); text/legacy-position/extmark repairs are described below, whilestrwidthand 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)
- Plugin:
- Remaining text, legacy-position and extmark coordinate seams — text get/set and legacy positions now use byte columns;
getcurposretains stickycurswantfrom 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 modeleddetails/virt_textcorrectly. 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)
- Plugin:
zzcenters wrapped lines on the cursor’s display row, not the line’s first row —scrollToCursormeasured the cursor’s line withcharCoords(Pos(line, 0)), which on a wrapped line is the box of the first display row.zztherefore 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,smoothscrolloff),zzon the final character of a long line gives topline 12 at 3 display rows, topline 18 at 15, and topline 21 withskipcol1280 at 38 — Vim centers the whole buffer line, keeps the topline at a whole-line boundary, and scrolls within the line viaskipcolonly 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 tozt/z<CR>/zb/z-as well. Single-row lines are byte-identical to the previous formula, so unwrappedzzis unchanged. (#183)- Fork:
~/Repos/codemirror-vim/src/vim.js(scrollToCursor)
- Fork:
- Out-of-vault configurations open in the default editor —
Open configuration in default editorpassed the resolved path straight toApp.openWithDefaultApp(), which resolves its argument through the vault adapter:getFilePath()joins onto the vault base path. A configuration found outside the vault — viaglobalConfigSearchin Obsidian’s userData directory, or an absoluteluaConfigPath/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’sshell.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, Electronshellaccess),src/main.ts(call site)
- Plugin:
just checktype-checked with whatever TypeScript was onPATH— the recipe called baretsc, so with TypeScript 7 installed globally it failed instantly ontsconfig.json(12,29): error TS5108: Option 'moduleResolution=node10' has been removed. The project pins^5.8.3, wheremoduleResolution: "node"is still valid, so the failure was entirely a function of the developer’s global install; every other line in the recipe goes throughnpm runand picks upnode_modules/.bin.bumpandlinthad the same defect forprettier, 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 ranjust, 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)
- Build:
:changesopened nothing at all — the command was registered only insetupVimSubsystems(), butreloadFeatures()(reached from cold start viamain.ts) tears every registration down and then rebuilds only those registered through the centralregisterExCommands()path. Teardown did not remove an ex command;src/vim/registration.tsre-defined it asnoopEx, so:changesstayed recognized — the fork never reportedNot 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’sundefineEx(), which the fork has exported all along for exactly this, and:changesis registered in the rebuilt path alongside:jumps. An audit of thedefineAction/defineMotion/defineOperator/mapCommandregistrations found no other command reachable at cold start but absent from the rebuild, so:changeswas the only casualty.- Plugin:
src/vim/registration.ts(removeRegistration),src/workspace/commands.ts(registerExCommandstakes theChangeList),src/main.ts(both call sites; one-off registration removed)
- Plugin:
:edit!/:e!created a junk note named!instead of reverting the buffer — the fork’sparseInput_ends a command name at the first non-word character, so:edit!parses as commandeditwith argument!.matchCommand_resolvededitthroughcommandMap_['e']andcreateEditCommandcallednavigateWithJump(app, '!', ''). Observed in a live vault: the active file went fromWelcome.mdto!.mdand the buffer emptied. The separately registereddefineEx('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.:editnow detects the attached bang and delegates to the existing force handler; the unreachable registration is gone.- Plugin:
src/workspace/commands.ts(createEditCommand,registerExCommands)
- Plugin:
:foldwas only reachable by accident, andfois a contested abbreviation — the fork keys its ex dispatcher by abbreviation (commandMap_[shortName]), and two commands claimfo:fold(src/fold/commands.ts) andforward(src/workspace/commands.ts).forwardregisters later, socommandMap_['fo']names it, andmatchCommand_('fold')walks longest-prefix-to-shortest, findsfo, sees it namesforward, rejects it ('forward'.indexOf('fold') !== 0) and returns nothing. What had been keeping:foldalive was the old teardown:defineEx(name, '', noopEx)defaults its prefix to the full name, so removingfoldcreated acommandMap_['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,:foldwas already unreachable.VimRegistration.defineExnow creates that full-name anchor deliberately, so exact spellings resolve regardless of registration order, andundefineExstill removes every entry naming the command so teardown stays honest.fois the only contested abbreviation across alldefineExcall sites.- Surfaced by
foldopen-golden.e2e.ts, whosek/3jcases moved by raw buffer lines becausevim.cmd("4,6fold")was silently creating no fold at all. - Plugin:
src/vim/registration.ts(defineEx,removeRegistration)
- Surfaced by
:violations!never cleared anything — same parse, same cause: it resolved to plain:violationsand printed the list it was asked to discard. The bang is now handled inside theviolationshandler and the unreachabledefineEx('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
- Plugin:
vim.optwrote string options into settings without validating them — the__newindexhandler insrc/lua/api.tspassed the raw value straight toonSettingOverride, consulting neithervalidValuesnor the normalizer that the vimrcsetpath has always applied. Illegal values therefore landed in the settings object; legal ones only appeared correct because the subsequentsetOptioncall happened to write a normalized value over the top. Found by drivingcursorlineoptthrough the realvim.optpath —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.optstring-option write path)
- Plugin:
cursorlineopthad no observable effect, and the cursor line’s number stayed highlighted withcursorlineoff — the gutter appliedvim-motions-line-num-currentto the cursor line whenever a line number was drawn, consulting neither option. Neovim gates this: “CursorLineNr Like LineNr when ‘cursorline’ is set and ‘cursorlineopt’ containsnumberor isboth, for the cursor line” (runtime/doc/syntax.txt), anddrawline.crequireswp->w_p_cul && (culopt_flags & kOptCuloptFlagNumber)—CursorLineNris never used while'cursorline'is off. BecausecreateCursorlineDecoration('number')correctly returns an empty extension, the default configuration (cursorline: true,cursorlineopt: 'number') meantcursorlinedid nothing observable at all: the highlight users saw came from the gutter unconditionally, and settingcursorlineopt=lineorcursorline=falsedid not remove it. Both renderers are fixed — the standalone line-number gutter and the unified statuscolumn build the class independently — and the relative-number0on the cursor line is still emitted, since only the highlight is gated, not the number. The three divergences this originally left open — noscreenline, anumberdefault where Neovim usesboth, 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)
- Plugin:
- Four hand-rolled copies of
getEditorView()—src/ui/hint-mode.ts,src/vim/table-cell-cursor-guard.ts,src/vim/table-debug-state.tsandsrc/vim/vim-api.tseach re-derivedview.editor.cm, three of them reproducing the sametry/catch. None was wrong, but they are the sites from which the.cm.cmdefect below is re-derived, so they now call the canonical accessor.src/editors/embeddable-editor.tsis untouched: itsthis.editoris the embeddable editor abstraction, not an ObsidianEditor. - Every gutter setting was a no-op until Obsidian restarted —
iterateEditorViews()readeditor.cm?.cmand required adispatchmethod on the result. ButEditor.cmis the CM6EditorView;editor.cm.cmis the CM5 compatibility adapter the vim engine attaches, and that adapter has nodispatch. The guard therefore rejected every leaf and the callback ran for no editor at all, soreconfigureLineNumberGutter,reconfigureSignColumnGutter,reconfigureFoldColumnGutter,reconfigureStatusColumnGutterandreconfigureCursorlineHighlightall dispatched their compartment reconfigure into nothing. Toggling number, relativenumber, signcolumn, foldcolumn, statuscolumn or cursorline — from settings, vimrc orvim.opt— stored the value and updated thevim-motions-line-numbers-activebody 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?.cminsrc/, against 49 uses of the canonicalgetEditorView()accessor thatAGENTS.mdalready mandates for exactly this property; the call site now uses it, with aninstanceof MarkdownViewnarrowing 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)
- Plugin:
Tests
-
Source-derived documentation guard —
test/unit/lua/api-status-counts.test.tsreusesapi-inventory.tsto 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 intest/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.tsmeasures 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’sline-heightas 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 is0.00px. The requestedalign-items: centerwould 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 number72pxlow (row 4 of 7), and forcing the gutter element’sline-heightto24px— which needs!importantto beat Obsidian’s inline style — drifts the heading to3.5px, both against a2pxtolerance. -
cursorlineoptis asserted through the realsetandvim.optpaths —test/specs/cursorlineopt-config.e2e.tsloads an actual.obsidian.vimrcand.obsidian.init.luarather than assigningplugin.settings, covering comma normalization, theculoptalias, reversed order and rejection on both paths. It found thevim.optvalidation bypass above on its first run. Worth recording precisely: the vimrcnormalizehook is load-bearing only for rejection — valid values are normalized downstream bysrc/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.0pxon Linux and2.5pxon Windows against a2pxtolerance. 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, ~16pxpadding-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 gutterline-heightbreaks the two heading cases (3.53). -
screenlineedge 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.tsadds a heading that is tall and wrapped, the one case where issue #184’s two failure modes coincide, which fails at77.16pxunder 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 assertingnot.toBeNull(), which also acceptsDirection.LTRand so passed without ever entering RTL. The wrapped-block guard now measures against a real single-row line instead of a48pixel constant. -
cursorlineoptgrammar and migration are unit-tested,screenlinegeometrically —test/unit/cursorline-option.test.tscovers ordering, thebothalias, the rejectedline+screenlinepair, duplicates and whitespace;test/unit/settings-migration.test.tscovers the pin. Thescreenlinee2e 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 — aDecoration.linecannot satisfy it. Negative controls: deleting the mutual-exclusion and duplicate guards yieldsexpected 'line' to be nullandexpected 'number' to be null; making the migration a no-op yieldsexpected 'both' to be 'number', the exact surprise it exists to prevent; routingscreenlineto the line decoration makes the measured heightnullin bothscreenlinecases. -
CursorLineNrgating is covered in both gutters —test/specs/cursorline-number-highlight.e2e.tsruns the same four-case truth table (cursorlineopt=number/both/line, pluscursorline=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.tstogglesfoldcolumn,cursorline,signcolumnandstatuscolumnat 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 theiterateEditorViewsfix reverted, 4 of the 5 cases fail; the fifth passes correctly because it asserts an absence (cursorlineopt=numberinstalls 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-cmast-grep rule — flags$X.cm.cmand its optional-chaining forms acrosssrc/**(excluding vendored fengari). Shown to fire on the real defect atsrc/main.ts:5390and 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 Luanow asserts that it applied —getGutterState()had computedhasLineNumbersfrom 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 theiterateEditorViewsdefect 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 optionalget-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 intest/fixtures/neovim-coordinate-controls.mdwithout changing assertions. -
mini.comment fixture pin — Phase 0 replaced the moving
mainref with27a29d6b949b9497f80a0a03421e89fed71d8c37intest/fixtures/test-plugins.jsonfor reproducible existing operation tests. -
Configuration directory reveal — 12 further cases in
test/unit/util/open-path.test.tsplus 4 registration cases intest/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 revealingparentDirOf(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 thenulla missing command returns, andexecuteCommandByIdreturnsfalsefor an unknown id instead of throwing, so atoBeNull()error check could not see the absence either. Both now assert presence —not.toBeNull()andexecuted === true— and all four fail when the registration is removed. - The pre-existing
open-configurationexecution 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 reportsExpected: 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.
- The e2e control caught two of its own four cases passing with the command registration deleted:
-
Out-of-vault config opening — 9 cases in
test/unit/util/open-path.test.tscovering vault-relative routing, absolute/tilde/Windows external routing, shell rejection, shell throw, and mobile. Verified red first by restoring the pre-fix unconditionalopenWithDefaultApp()call: 7 of 9 failed, with the absolute-path case reportingopenWithDefaultAppcalled 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
zzregression coverage — 3 cases intest/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 #143ztcase), 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’sbeforeSuiteguard that skipped the vim-mode toggle forSpike:-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.tsrunsVimRegistrationagainst 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 givesexpected '' to be 'UNKNOWN'; removing the full-name anchor givesexpected 'UNKNOWN' to be 'fold'. Either defect flips a test rather than passing quietly. -
solo-self-reported-successast-grep rule —.ast-grep/rules/gains a gate for the defect class above.vitest/expect-expectis syntactic and cannot see it: it confirms anexpect()exists and nothing more. The rule fires on anit()that assertstoHaveProperty('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:wcase 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!!overlayandchildren.length > 0. Syntax cannot tell a hardcoded flag from a measured one, so the rule stays pinned to thesuccessidiom. The 2 genuine finds among those 11 were fixed regardless.
- 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
-
57 e2e tests that could not fail have been given real assertions —
test/specs/**contained 190it()blocks assertingexpect(result).toHaveProperty('success', true), and in 72 of them that was the only assertion.successis set unconditionally by the test itself on the line after calling avoid-returning function, so the assertion proved only that no exception crossed theexecuteObsidianboundary. 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
Fixedentries 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.tswrote.obsidian.vimrcbeforereloadObsidian({ vault: 'test-vault' }); the reload replaced the launched vault and discarded the file. The observed default\leader was therefore correct:vimrcCommandCountwas 0,vimrcWatchPathwas null, and.obsidian.vimrcdid 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 fromLeaderRegistryand assert the real EasyMotion label count; replacing thewsuffix with unmappedxproduced 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’safterTestno longer force-removes modal DOM. It closes trackedModalinstances first, becauseel.remove()leaves the ObsidianScopeon the keymap stack to swallow the next test’s first keystroke — the mechanism behind thegOfalse 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’safterTestforce-removes modal DOM without closing the instance, leaving the ObsidianScopeon the keymap stack to swallow the next test’s first keystroke. Separately, onlyVimInfoModalrenders.vim-motions-info-modal-title— picker/SuggestModalsurfaces 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>Wand 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
executeObsidianblock that had been copy-pasted across 11 spec files:handleEx()reports what the fork actually did with an ex command — includingunknownCommand, set when the fork emitsNot an editor command, which is the control that makes a fabricated command name fail — plusdispatchedCommands,getWorkspaceSnapshot(),loadTwoFileWorkspace(),getVimMarkLetters()andgetInfoModalTitles(). - Save-type commands deliberately assert
dispatchedCommandsrather 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.mdin 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
- This sweep is what found the three
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.mdalso records the fork’sscrollToCursordisplay-row measurement and its Vimskipcolclamp.KNOWN_LIMITATIONS.md: D4/D5/D6 and extmark normalization deviations, remaining coordinate seams, quarantinedstrwidth, 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:changesand: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.:changeswas recorded asVimInfoModal.open()completing without producing DOM; in fact the closure never ran, because teardown had re-defined the command asnoopEx.:e!was recorded as “reverts to the last saved disk state, not thesetupEditorcontent”; 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 newtest/helpers.tsexports (handleEx,getWorkspaceSnapshot,loadTwoFileWorkspace,getVimMarkLetters,getInfoModalTitles), whyokalone is a vacuous assertion for avoid-returning ex handler, and the three traps that each produced a wrong diagnosis this cycle — theVimInfoModal-only title selector, modal instances that must be closed or theirScopeeats 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”,:changesas “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: thecursorlineoptoption tables carry the five canonical values, the newbothdefault, that comma lists are accepted, and that existing vaults keepnumber.KNOWN_LIMITATIONS.md: the paragraph listingscreenline, thenumberdefault 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 writtendata.json.AGENTS.md,CONTRIBUTING.md: theeditor-view-double-cmast-grep rule, with the accessor to use for the CM6 view versus the CM5 adapter. Both rule lists also gainsolo-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.tsadded to thevim/tree, andcursorline.ts’s entry corrected — it no longer describes “number/line/both modes” now thatscreenlineis drawn by a measured layer rather than a line decoration.README.md: the line-numbers feature bullet names the fullcursorlineoptgrammar 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 theiterateEditorViewsdefect, not a design constraint, so the#101record, thelinenumbermodenote and thestatuscolumnnote are all corrected.docs/configuration/settings.mdalready 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 onApp.openWithDefaultApp()/App.showInFolder(), thesrc/util/open-path.tsrouting that satisfies it, and the rule thatshowItemInFoldertakes the file rather thanparentDirOf(file).showInFolder()added to the list of typed unofficial APIs in use.CONTRIBUTING.md:src/util/open-path.tsadded to theutil/tree, andexternal-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 returnsvoid, 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 viaCapacitorAdapter.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 exploratorytest/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, orraw. 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.tsand the five file-previewing sources (live getter threading) renderedembeds images/svg/canvasnatively, 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.minAppVersionis 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.
- Plugin:
Fixed
- The picker no longer reads binary files to preview them.
readFilePreviewcalledvault.cachedRead()on everyTFileregardless 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 singlerequestAnimationFrame(~16 ms), but OS key repeat is ~30 ms, so every keystroke issued its own read and render — roughly 30 previews per second of heldj. It now uses a 100 ms leading-plus-trailing debounce, matchingOilManager.previewDebounceTimer: an isolated keypress still previews immediately, while a burst collapses to two. - The preview pane no longer leaks
Componentinstances. OnlyrenderMarkdownPreview()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 singleclearPreview()now owns unload → null → empty and is called on every transition, includingonClose(). - 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.catchunhandled — despitedocs/development/picker-api.mddocumenting that exceptions inpreview()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 monotonicpreviewGeneration, following the existingsearchGenerationconvention in the same file.
Tests
test/unit/picker/preview-policy.test.ts(13 cases) pins the read policy: nocachedReadfor 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,cachedReadwas called once on a 5,000,000-byte PDF inrendered,hiddenandraw.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.mdlisted three picker settings as unavailable via vimrc when all three have hadsetoptions.picker,pickerLeaderMappingsandpickerMatcherEnginewere under “Settings not available via vimrc” and showed—in their Lua and Vimrc columns, whileconfiguration/vimrc.mddocumentedset picker,set pickerleadermappingsandset pickermatchercorrectly — the two pages contradicted each other. The stale entries are removed and the columns now name the real options. Noticed while addingset pickerpreviewalongside them.
[0.148.0] - 2026-09-08
Added
- Dead-code gate —
npm run lint:deadcode(knip) is blocking in CI, andnpm run verifynow 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
TableEditoractions supersede them), unused treesitter JS-API and query-cache surface, snippet autocomplete helpers, andsrc/vim/jumplist-bridge.ts, whosecreateJumpListBridge()had no caller — the jump list works through theJumpListclass instead. knip.jsoncis JSONC specifically so every ignore entry carries its reason inline. The categories are: vendored fengari, dependencies selected by string name inwdio.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.
- Deleted: superseded table manipulation helpers (Obsidian’s native
- Test-quality gate —
test/was in ESLint’sglobalIgnores, so 346 files and 4,259 test blocks had never been linted at all.@vitest/eslint-pluginnow coverstest/unit/**andeslint-plugin-wdiocoverstest/specs/**, both blocking. It surfaced 166 findings, all fixed rather than suppressed — noeslint-disablewas 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-expectships off in the plugin’s own recommended set and is forced on here. - 57
vitest/expect-expect, 13no-conditional-expect, 10no-identical-title, 7valid-expect, 2no-standalone-expect, plus one tautological self-comparison and one constant-foldednull ?? {}that made a test a silent duplicate of the next one. wdio/no-pauseis off (2902 occurrences of an establishedbrowser.pause()idiom — a flakiness question, not a vacuity one), and the general lint backlog intest/is switched off there and separately owned. Neither is suppressed forsrc.expect-expectis syntactic and trusts any helper named inassertFunctionNameswithout inspecting it, so it proves a test asserts something, never that it asserts the right thing.luaExpectOkandrunwere added only after reading their definitions and a call site;runis scoped totest/unit/lua/**andtest/unit/fengari/**because the name is generic enough that trusting it suite-wide would let any future function so named satisfy the rule.
- 46
negative-controlskill (.agents/skills/negative-control/SKILL.md) — generalises the “the test MUST fail first” requirement fromissue-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-reprokeeps its own workflow and the two cross-reference each other.require()resolves synchronously — every.luafile underlua/is read into memory when the configuration loads, andrequire()resolves from that snapshot. This unblocks lazyrequire, 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 withmodule '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 plainlua_pcallthat cannot yield. Only a cache miss was ever affected —package.loadedhits 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
requirechunk as chunk arguments, not globals, so sandboxed user Lua has no handle on them. - Measured against the flash.nvim diagnostic:
require_in_callbackwent fromblocked: … async APIs …toworks, andConfig.get().search.multi_windowfrom an error totrue. flash now runs to its LuaJIT FFI dependency, which is architectural and not fixable in a pure-Lua VM.
- Plugin:
vim.ui.selectandvim.ui.input— Neovim’s UI-hook namespace, backed by the existing Telescope-style picker and input modal.vim.uiis 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 avim.keymap.setcallback — the most common call site, where a yield-based design would hard-error.on_choicereceives the original Lua value (items are commonly tables) plus a 1-based index;on_confirmdistinguishes''(empty confirm) fromnil(cancel).format_itemis applied eagerly, matching Neovim’s own default implementation.- Plugin:
src/lua/ui-api.ts(new),src/lua/loader.ts(injection + wiring),src/main.ts(openUiSelectpicker binding) - Where no selection UI is available,
vim.ui.selectraises rather than settling withnil— a caller cannot distinguish anilsettle from “the user cancelled”. vim.ui.open(path, opts?)openshttp(s)://targets in a new window and everything else with the system handler, returning Neovim’svim.SystemObj|nil, nil|stringshape. On mobile, or with no handler,nil, errmsgis the correct answer rather than a fudge.opts.cmdis 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.
- Plugin:
- Picker reports cancellation —
PickerOptions.onCancelfires exactly once when the picker closes without a selection, andPickerModal.closeActive()closes a live picker. Needed by any caller that must distinguish “chose nothing” from “chose something”, such as a Neovim-stylevim.ui.select.confirmSelectioncloses the modal before dispatching the selection, soonCloseruns first; adidConfirmflag set synchronously beforeclose()keeps a successful selection from also reporting a cancel.- Plugin:
src/picker/picker.ts(didConfirm,onClosecancel dispatch,closeActive),src/picker/types.ts(onCancel)
- Plugin:
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 CodeMirrorViewPluginthat coalesces work into onerequestAnimationFrameper frame rather than dispatching from insideupdate()(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_winreturningfalseskips 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_lineandon_rangeraise a Lua error naming the unsupported key rather than being silently accepted;ephemeralextmarks 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.
- Plugin:
- Extmark
hl_eol,strict, and priority ordering —nvim_buf_set_extmarknow parseshl_eol(extends the highlight to the end of the line containing the range end) andstrict(out-of-range positions clamp instead of dropping the mark). Overlapping marks are ordered bypriority, deterministically and independently of insertion order.- Plugin:
src/lua/api.ts(opts parsing),src/lua/extmarks.ts(hlEol/strictinExtmarkOpts, line-awarebuildDecorations, priority-aware sort) - Known gap:
priorityorders decorations but does not yet decide which wins visually — CM6 marks carry no z-index andDecoration.set(..., true)re-sorts. Recorded inKNOWN_LIMITATIONS.md.
- Plugin:
- LuaJIT
bitlibrary — Neovim runs LuaJIT, which Neovim documents as its permanent plugin interface, so plugins reach forbit.band/bor/lshiftrather 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, not4294967295) and the distinction between logicalrshiftand arithmeticarshift. 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)
- Plugin:
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
- Plugin:
nvim__redrawis a warn-once stub again, deliberately truthy — it briefly read asnilso that flash’sif vim.api.nvim__redraw thenprobe would take its fallback. That was right forhighlight.cursor, whose fallback isnvim_buf_set_extmark, but wrong forhacks.setcursor, whose fallback is LuaJIT FFI and which is called unguarded on every keystroke throughUtil.get_char. One name, two opposite correct answers; the truthy stub avoids throwing on the hot path, at the cost ofhighlight.cursorno longer drawing its cursor highlight. Thenil-reading dispatch tier introduced for it has been removed rather than left with no members.- Plugin:
src/lua/api.ts
- Plugin:
- Vim regex translation —
vim.fn.searchpos,vim.fn.splitandvim.regexcompiled their pattern withnew RegExp, so Vim syntax silently matched nothing:\Vand\Care identity escapes in JavaScript, making\Valpha\Ca search for the literalValC. A shared translator now handles magic levels (\v,\m,\M,\V), the case flags\c/\C,\zs/\zeas 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.mdpreviously 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.
- Plugin:
- Indexed scope access:
vim.bo[buf],vim.b[buf],vim.wo[win],vim.w[win],vim.t[tab]— Neovim allows bothvim.bo.filetypeandvim.bo[bufnr].filetype, and plugin code uses the indexed form freely. Our proxies accepted string keys only, so an indexed access resolved toniland the caller failed withattempt to index a nil value. A numeric or nil key is now validated as handle0and returns the scope table. This closed the last of three blockers preventing flash.nvim from rendering:flash/cache.luareadsvim.bo[buf].filetypeandvim.b[buf].changedtick, and with both fixedflash.state.new{...}completes and writes extmarks into the document.- Plugin:
src/lua/api.ts(isScopeHandleKey, indexed branch on all five scope proxies)
- Plugin:
nvim_list_bufs()andnvim_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 withnvim_list_wins()and the current-handle APIs.nvim_tabpage_list_winsvalidates its argument through a newrequireTabpageZeroguard. Measured impact: flash.nvim’sCache:_update_wins()overwritesstate.winswith the filtered result ofnvim_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 intoSUPPORTED_NVIM_API_FUNCTIONS)
- Plugin:
- Unicode index conversion for
vim.fn—strchars(s, skipcc?),charidx(s, byteidx, countcc?), andbyteidx(s, nr)convert between UTF-8 byte offsets and Vim character indices.strcharscounts composing marks separately unlessskipccis set;charidxandbyteidxfold them into the preceding base character, matching Vim. All three are on flash.nvim’s default label-positioning path.- Plugin:
src/lua/fn.ts(buildCharSpansbyte-span mapping, three registrations)
- Plugin:
vim.fn.wincol()andvim.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 matchesnvim_list_wins().wincolis on leap.nvim’s search path;winlayoutis on flash.nvim’s window-layout save path.- Plugin:
src/lua/window-info.ts(getCursorWinCol),src/lua/fn.ts(registrations)
- Plugin:
- Window-local option scope (
vim.wo) — replaces the warn-and-return-nilplaceholder with a real proxy.wrapreports CodeMirror’s line-wrapping state; writes shadow the resolved value; every other key falls back to the global scope, matching Neovim where an unset:setlocalvalue resolves to the global one. Required by leap.nvim’s core search loop.- Plugin:
src/lua/api.ts(readWindowOption, window-option shadow,vim.woproxy),src/lua/loader.ts(getWindowOptioncallback)
- Plugin:
vim.bo.iminsertandvim.bo.fileformat— buffer-local options read by flash.nvim and leap.nvim on every invocation, and by nvim-surround.- Plugin:
src/lua/loader.ts(getBufferOptioncases)
- Plugin:
Changed
- Global option fallbacks —
vim.o/vim.goresolve engine → shared shadow store → defaults (eventignore,selection,cmdheight,columns,lines,cpo, theme-derivedbackground) →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 returnedErrorobjects for unknown fork options)
- Plugin:
- Plugin query retention — downloads retain
.scmquery files under isolatedlua/{owner}__{repo}/queries/roots and refresh the snapshot before Lua resumes. Older cached plugins must be re-fetched to acquire previously discarded queries;.scmedits 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)
- Plugin:
nvim_create_namespacereturns unique IDs — previously hardcoded to0, 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 touchedvimExtensionSlot, which onlysetupVimSubsystems()populates, and that runs fromonload()andenableVim()only.animatedCursor,enableSnippets,snippetTriggerModeandenableUndoTreewere therefore restart-only, andenableUndoTreenever reached a reload path at all. Re-runningsetupVimSubsystems()is not an option — it is a one-shot builder that registers global handlers and constructs managers, Lua and autocmd state — andteardownVimSubsystems()is far too destructive for a settings change. Each gated feature now owns a nestedExtension[]that is pushed intovimExtensionSlotonce and whose contents are swapped in place, followed by a singleworkspace.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 changingsnippetTriggerModeleaves an in-progress snippet session intact. (#181)- Plugin:
src/main.ts(setSlotEnabled,populateRuntimeSlots,refreshRuntimeExtensionSlots, five feature slots, extension builders extracted fromsetupVimSubsystems),src/settings.ts(enableUndoTreeadded toRELOAD_KEYSand to the imperative handler)
- Plugin:
- Cursor shape changes now reach the animated cursor without a restart —
cursorShapeshas two independent consumers, and only one was broken. The fork readsstate.vim.cursorShapeslive on every render and already tracked settings changes at runtime. The animated cursor keeps its own copy, made bysetCursorShapes(), which onlysetupVimSubsystems()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-appliessetCursorShapes)
- Plugin:
- 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 onrunning, 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 againstcachedScrollTop, 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(wakeRequestedconsumed byscheduleNext),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.
- Plugin:
- Teardown no longer resurrects the animated-cursor manager —
teardownVimSubsystems()destroys the manager, but CodeMirror destroys the controllers only on the laterupdateOptions(), soCursorController.destroy()calledgetAnimatedCursorManager()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 usespeekAnimatedCursorManager(), 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())
- Plugin:
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 intextObjectManipulationwas gated oninclusive. 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 oninclusive)
- Fork:
- Surround
ysi$/ysa$and every other registered text object were dropped —ysstores its text object in ays_motionsub-state and, on the next key, called the fork’s built-intextObjectManipulationdirectly. 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 pendingysivanished 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 byysdot-repeat. (#179)- Fork:
~/Repos/codemirror-vim/src/vim.js(runTextObjectMotion, used byhandleSurroundSubState’sys_motionbranch and byrepeatCommand)
- Fork:
vim.keymap.set("", lhs, rhs)only mapped normal mode — Neovim’s:h map-modesdefines the empty mode string as Normal + Visual + Select + Operator-pending, butgetModeList()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 pressedvor an operator. The empty string now expands ton,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 bothvim.keymap.setandvim.keymap.del
- Plugin:
- Animated cursor did not follow the text while scrolling, and left an uncleared phantom behind —
CursorController.update()already comparedscrollDOM.scrollTop/scrollLeftagainst its cached values, but it only runs when CodeMirror produces aViewUpdate, and scrolling inside the already-rendered viewport produces no transaction at all. The only recovery was the 500 ms staleness check intick(), 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 panecoordsToRect()returnednullandrefreshTarget()returned early without clearingcachedRect, sotick()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. Apassivescrolllistener onscrollDOMnow 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(onScrolllistener registered in the constructor and removed indestroy(),refreshTarget()clearscachedRect/cachedShapeRect,coordsToRect()vertical intersection test)
- Plugin:
- 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()cachescharTop/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:fillRectdrew the block at the new screen position whilefillTextpainted the letter at the old one. Because the frame reports only the block’s rect as dirty, the stranded letter fell outside every subsequentclearRectand 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)
- Plugin:
- Table navigation shared its pending-key state across split panes —
pendingDand the count buffer lived at module scope whileTableNavControlleris a CodeMirrorViewPlugin, instantiated once perEditorView. Pressingdin one pane and then a key in another consumed the first pane’s pending state, sodfollowed bydin a different table deleted a row immediately instead of starting its owndd. 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)
- Plugin:
- Picker source timeouts leaked a live timer per call —
Promise.racesettles but does not cancel the loser, so everyitems(),search()andpreview()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)
- Plugin:
- Visual-line pending selection lost its expiry in split panes — the selection was stored per
EditorViewin aWeakMapbut 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
- Plugin:
- 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
- Plugin:
- 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.
createBridgeExtensionhad no caller anywhere, so the per-view incremental-parsingViewPluginhad never run — treesitter worked only becausejs-api.tsand the Luavim.treesitterAPI parse on demand.main.tsinstalls it throughenableTreesitterBridge()after the Markdown grammars load, via a mutable extension slot andworkspace.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)
- Plugin:
- Plugin:
vim.bowrites were silently discarded —setBufferOptionwas an empty function, so everyvim.bo.x = yassignment did nothing and the next read returned the computed default. Writes now round-trip through a per-file shadow store, andexpandtab,shiftwidth,softtabstop,tabstop, andtextwidthare 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/writeBufferOptionshadow store),src/lua/loader.ts(ENGINE_BACKED_BUFFER_OPTIONSforwarding)
- Plugin:
- Guarded
nvim__redrawprobes crashed instead of degrading — thevim.apidispatch metatable raises on property read for unregistered names, so flash.nvim’sif vim.api.nvim__redraw thenand leap.nvim’spcall(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 asnil: 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 itselsebranch draws vianvim_buf_set_extmark).nilis also what leap’spcallexpects on a Neovim build without the API.- Plugin:
src/lua/api.ts(ABSENT_NVIM_API_FUNCTIONS, dispatch metatable)
- Plugin:
- Command-line, history, and mapping probes raised instead of degrading —
getcmdline,setcmdline,getcmdpos,getcmdwintype,wildmenumode,complete_info,histadd,histdel, andmapsetwere 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, newvoidReturnFnsset formapset)
- Plugin:
- Lua iterator pipelines — real
vim.iterfor list-like tables, map-like tables, iterator functions, and callable tables, with 26 methods.rpop,count, andsizeare 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)
- Plugin:
- 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/programmaticfeedkeysare 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)
- Plugin:
- 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, andnvim_win_get_config(0)reports a non-floating window (relative = ''). Authoritative registration totals are 60 realvim.apiimplementations and 79 realvim.fnimplementations, 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)
- Plugin:
- Named Treesitter query files — resolve
query.set()overrides, userlua/queries/{lang}/{name}.scm, lexically ordered pluginlua/{plugin}/queries/{lang}/{name}.scm, then bundled Markdown/Markdown inline/HTMLtextobjects. 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)
- Plugin:
- 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 newvim.fn.*functions) - Plugin:
src/lua/api.ts(16 newnvim_*API functions) - Plugin:
src/lua/stdlib.ts(upgradedvim.validate,vim.keycode,vim.notify_once,vim.versionnamespace 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 forvim.fn.input()prompt) - Plugin:
src/main.ts(registered extmark extension) - Styles:
styles.css(.vim-motions-input-modal-inputstyles)
- Plugin:
- 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.versionnamespace — 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()withhas(),vim.version.last(), plus__tostring/__eq/__ltmetamethods on version objectsvim.validate()full Neovim spec — both old table form (vim.validate({ name = { value, "string" } })) and new positional form (vim.validate("name", value, "string")) withoptionalflag 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 withvirt_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 portsvim.fn.input()— async user input prompt via Obsidian modal. Enables nvim-surround and other interactive Lua pluginsoperatorfuncoption routes — direct Lua functions, function-name strings, andnilclearing now share handling throughvim.opt,vim.o,vim.go,nvim_get/set_option, andnvim_get/set_option_value. The previously advertisedvim.o.operatorfuncpath now works with the fork’sg@operator.- Plugin:
src/lua/api.ts(shared operator callback read/write helpers)
- Plugin:
- Termcode identity-function bug —
nvim_replace_termcodesemits real Neovim key bytes (<CR>becomes"\r"), andnvim_feedkeysdecodes 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)
- Plugin:
- Query iterator source and row arguments —
iter_captures/iter_matchesnow 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)
- Plugin:
- 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 bygd, 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)
- Plugin:
- Unhandled rejection on failed jump navigation —
openJumpEntry()was invoked as a bare floating promise, so a rejectingleaf.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)
- Plugin:
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 thatupdateOptions()left existing ViewPlugin instances alone rather than rebuilding them. The fourth extends that acrossenableUndoTree,enableSnippetsandsnippetTriggerModeto 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 usesetPluginSettingAndReload— that helper assignssettings[key], so a dotted key becomes a flat property of that literal name and the realcursorShapesobject is never touched. The first version of this spec did exactly that and reported the fix as not working. spike-mutable-array-destroyfixed, after its own diagnostics identified the cause. The Windows-only failure was the spike asserting a guaranteeregisterEditorExtensionnever made: the keydown fired from anembedded:table-widgeteditor, which still had the plugin because embedded editors are handed extensions at construction and are not reconfigured byworkspace.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-editorin the document. Not a regression — it failed identically onf1b5626.- 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.tsasserting 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 inspike-mutable-array-destroy, which shows aViewPluginbeing 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 theupdateOptions()indisableVim()makes the assertion reportoff: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.tsclose the gap left by the slot work, which had only shown the slots do not disturb each other. Discrimination was established by sabotage — reducingrefreshRuntimeExtensionSlots()to the animated-cursor slot alone, leaving setup intact, which reproduces the original defect. Two failed:snippetTriggerModekept 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 —createSnippetTabKeymapre-readsenableSnippetson every keypress, so that trigger already self-guarded. test/specs/animated-cursor.e2e.tsstrengthened. 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 ofcoordsAtPos, 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.tsfor the manager singleton:peekreturns null after teardown wheregetwould build a replacement.CursorControlleris 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.tsfor #178, written before the fix. Five failed on the unfixed code (di{,di(,di[,dibbefore the pair, anddi(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 theavariant’s forward search was already there, anddi(positioned after the only pair, proving the fix does not search backwards. Expected values recorded againstnvim --clean. - 4 e2e cases in
test/specs/surround.e2e.tsfor #179, written before the fix and all four observed failing with the buffer unchanged (ysi$b,ysa$b,ysi=b, andysi$bfollowed by.). The fourth covers the dot-repeat call site, which resolved text objects through the same built-in-only path. TheysaBbcase intest/specs/vim-builtin/surround-golden.e2e.tsis the control for the shadowing rule: it failed with the first version of the fix, which let the registered blockquoteaBwin unconditionally over the built-in{}block, and drove the built-in fallback. - 5 e2e cases in
test/specs/lua-keymap-modes.e2e.tsfor #180, written before the fix. The visual case failed withline1 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-modejas 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 byno-useless-escapeoncetest/began being linted. The Lua lives in a JS template literal, so[[\"]]collapsed to[["]]before Lua ever saw it andq:findsearched for a bare"— whichstring.format("%q", …)always adds as wrapping quotes. The assertion that%qescapes inner quotes had never run. Confirmed by sabotage (assertion failed!at Lua line 24), then restored. - The
expect-expectandno-floating-promisefixes 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.tscover 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, includingtrikinstead ofstrikeand unwantedafter/more outerselection. - 27 real-WASM unit cases in
test/unit/fold/metadata.test.ts(23 extraction, provider, placeholder and state-identity cases) andtest/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.tscovering 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 sixwaitForKeycases intest/unit/easymotion-keypress.test.tswere 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 totest/unit/lua/coroutine-runner.test.tsfor abandonment release on destroy, timeout, and pre-registration rejection. 3 e2e cases intest/specs/lua-async-prereq.e2e.ts, driven throughvim.schedulebecause it is already async-capable. Thegetcharstrcase 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.tsfor #177 (module beside a custominit.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 acrosstest/unit/lua/module-snapshot.test.ts(multi-root indexing, shared budget ordering, one unreadable root not sinking the others) andtest/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 intest/unit/lua/package-require.test.ts(snapshot hit bypasses the adapter, resolution from a synchronous caller,init.luafallback, 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.tsfor 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.tshad its characterization assertions flipped — they asserted the async-requireblocker that this work removes. - 2 e2e cases in
test/specs/animated-cursor-scroll.e2e.tsfor #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 newcachedRect = nullpath 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 waytest/specs/animated-cursor.e2e.tsdoes:reloadFeatures()does not rebuild the editor-extension slot, so the canvasViewPluginis 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.
- 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
- CI pre-fetch gained
dirssupport 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.tsskips its flash-dependent cases when the fixture is absent. - 11 e2e cases in
test/specs/lua-vim-ui.e2e.tsforvim.ui(overridability, E7 keymap-callback invocation, non-blocking return,inputcancel vs empty confirm,opencontract, reload-while-open teardown, and P1–P4 of the third-party override idiom viatest-vault/lua/uiselect_shim.lua); 4 unit cases intest/unit/picker/picker-cancel.test.ts; 12 intest/unit/lua/decoration-provider.test.ts; 6 intest/unit/lua/extmarks.test.ts; 4 intest/unit/lua/api-compat.test.ts. - 11 unit tests in
test/unit/lua/fn.test.ts(Unicode index conversion,wincol/winlayoutgeometry and fallbacks, plugin-facing stub degradation,nvim__redrawguard survival) andtest/unit/lua/api.test.ts(vim.bowrite round-trip,vim.wocallback/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, andplugin-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
getwininfocases added totest/unit/lua/fn.test.ts; correctedtest/unit/lua/api.test.tsto expect"\r", not"<CR>", fromnvim_replace_termcodes. - 20 unit tests in
test/unit/lua/extmarks.test.tsfor extmark engine (set, get, delete, clear, virtual text, position tracking, range queries) - 27 new tests in
test/unit/lua/stdlib.test.tsforvim.validate,vim.version,vim.keycode,vim.notify_once - 16 new tests in
test/unit/lua/fn.test.tsfor newvim.fn.*functions - 11 new tests in
test/unit/lua/api.test.tsfor newnvim_*API functions - 1 updated test in
test/unit/lua/highlight.test.tsfornvim_create_namespaceunique IDs - 22 unit cases in
test/unit/jumplist.test.tsfor 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 viaobsidianPage.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 withgd(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 testCONTRIBUTING.md:controller.tsdescription covers the scroll listener, the cached-rect drop when the caret leaves the pane, and the intersection testAGENTS.md,CONTRIBUTING.md: the runtime extension-slot mechanism and the rule that a setting gating an extension must be handled inrefreshRuntimeExtensionSlots()and appear in a reload path in both settings implementationsREADME.md: animated-cursor resilience list includes scroll trackingAGENTS.md: fork description records the inner-bracket forward pair search andrunTextObjectMotion’s registered-object-then-built-in resolution order forysKNOWN_LIMITATIONS.md: new “Bracket text objects outside the pair” section; the surround parity section records thatysnow reaches registered text objects and how shadowed keys resolvedocs/features/surround.md:ysaccepts the plugin’s Markdown text objectsdocs/features/text-objects.md: bracket objects find the next pair ahead of the cursordocs/configuration/lua-config.md: new “Mode strings” table forvim.keymap.set, documenting every accepted mode character and the""expansion, plus a mapping example using itdocs/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.mddocs/configuration/lua-config.md: corrected thevim.fn.getcharstr()example, which showed the call inside avim.keymap.setcallback — the one context that cannot yield, so the documented snippet always raised. Replaced with thevim.scheduleform, plus the ten-second bound and the one-keypress-one-waiter ruleKNOWN_LIMITATIONS.md:getcharstr/getcharentry records shared-broker delivery order and the ten-second await boundAGENTS.md,CONTRIBUTING.md:key-broker.tsadded to the Lua module trees;coroutine-runner.tsdescription notes the abandonment hookAGENTS.md:require()resolution rewritten — snapshot-first, async only for runner-managed threads, chunk-argument injection, refresh points;module-snapshot.tsadded to the Lua compatibility module treeCONTRIBUTING.md:module-snapshot.tsadded to the source tree;package.ts,coroutine-runner.ts, andloader.tsdescriptions updatedKNOWN_LIMITATIONS.md: new “Module snapshot andrequire()” section — the reload boundary, refresh points, the four resource limits and their reportingREADME.md: Lua bullet notes synchronousrequire()resolutiondocs/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 caseNEOVIM_API_STATUS.md: corrected registration totals (audited historical baseline: 63/94/157 and 84/46/130 forvim.apiandvim.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 stalevim.o.eventignore/selection/cmdheight/columns/cporows that contradicted the documented resolution order, and refreshed the “Next candidates” matrixAGENTS.md:vim.booption set, newvim.woscope,vim.fncountCONTRIBUTING.md:fn.tsdescription and countREADME.md:vim.fncountKNOWN_LIMITATIONS.md: reconciled two contradictoryvim.fncounts (65 and 79) against the authoritative registry totaldocs/configuration/lua-config.md: newvim.fnrows,vim.botable additions and write semantics, new “Window-local options (vim.wo)” section, registered-surface countsAGENTS.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.scmblocker, 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 advertisedvim.apicount 59 → 60 andvim.fncount 77 → 79; expanded the Lua feature bullet and clarified working fork-backedoperatorfuncsupport.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-varsnow reports unused parameters (args: 'after-used'); the preset hadargs: 'none', which is why a parameter accepted and then ignored while the behaviour it should have driven was hardcoded survived review (#177). Vendoredsrc/lib/fengari/**is exempt — its signatures mirror the Lua C API.npm run format:checkis a CI gate, and Prettier is now a pinned devDependency. It had been resolving throughnpxat whatever version was current, so formatting was never actually enforced..ast-grep/rules/promise-owned-listener.ymlflags a listener installed inside a Promise executor. Verified against the pre-fixgetcharstrimplementation atsrc/lua/loader.ts:1601: the rule fires on it..ast-grep/rules/unguarded-disposer-loop.ymlflags cleanup loops that invoke disposers without isolating exceptions. It found all five instances in the tree, including thedestroyStateloop where a throwing finalizer would also have skippedlua_close.npm run verifyruns lint, format, and pattern checks together;npm run lint:patternsruns 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
keydownlistener 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 invim.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 throughcaptureKeys, 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
hintModeActiveflag and intercept lease are now acquired and released by the same owner as the listener, so they are balanced by construction rather than by everyreturnpath remembering to callcleanup(). - The five sites had been carrying
ast-grep-ignore: promise-owned-listenersuppressions since the rule landed. All are deleted;npm run lint:patternsis green without them.
- Plugin:
-
A throwing cleanup callback aborted every later cleanup —
destroyStateand four other teardown paths invoked disposers in a bare loop, so the first throw skipped every remaining disposer and whatever followed the loop. IndestroyStatethat includedlua_close, meaning one bad finalizer leaked the entire Lua state. All five now run throughrunCleanups, 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
- Plugin:
-
Two parameters were accepted and then ignored — surfaced immediately by the new unused-parameter gate, both the same shape as #177.
parseInlineContent(text, startIndex)ignoredstartIndexwhile its only caller passedinlineNode.startIndexand separately compensated for the offset itself; the parameter is removed rather than implemented.recordSearch(..., opts)threaded aFlashCharOptionsthrough 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
- Plugin:
-
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 agetcharstrprompt 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, andyieldWithPromisegained 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/getcharpass 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.
- Plugin:
-
Overlapping Lua callbacks corrupted
vim.v— the context backingvim.v.count,v:register,v:operatorandv:eventwas 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 readv: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(setVimVContextreturns the previous context, newrestoreVimVContext, 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.
- Plugin:
-
The Lua coroutine runner was never torn down —
runner.destroyAll()had no caller anywhere, so a configuration reload closed thelua_Statewhile 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, whichdestroyStateruns beforelua_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(registerStateCleanupfor runner and broker),src/lua/coroutine-runner.ts(guardedlua_sethookin thefinally)
- Plugin:
-
require()ignored the directory of a custominit.luapath — with Lua config path set tofolder/vim/init.lua, a module atfolder/vim/lua/regex.luawas unreachable:require("regex")searched onlylua/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 theconfigDirargumentinjectPackageAndRequirealready received was never used.require()now searcheslua/beside the configuredinit.luafirst, thenlua/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(rebuildwalks 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.
- Plugin:
-
Scroll offset centering misaligns when alternating up/down movement — with high
scrolloffvalues (e.g.,set scrolloff=999for centered scrolling), the cursor’s vertical position drifted ~17px when changing movement direction (move down, stop, move up). Root cause: theupdateListener-based scrolloff usedif/else ifto 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 theif/else ifwith a constraint-based algorithm: convert visual coordinates to document-relative positions, computeminScroll/maxScrollfor both margins simultaneously, clampscrollTopinto 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 fromselectionSet || viewportChangedtoselectionSetonly — viewport-only changes (e.g.,zt/zz/zbscroll 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 withcoordsAtPos+ midpoint, narrowed trigger toselectionSetonly)
- Plugin:
Tests
- 1 regression test in
test/specs/scrolloff-centering.e2e.tsfor #176 (cursor stays centered when alternating up/down movement withscrolloff=100)
Documentation
CHANGELOG.md
[0.146.0] - 2026-09-05
Added
- Table debug state inspector — new
:tablestate(:tables) ex command andwindow.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(:tablestateex 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)
- Plugin:
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 theTableNavControllerViewPlugin doesn’t receiveupdate()calls ordestroy()during view reconfiguration. All vim key processing was suppressed globally, leaving the editor unresponsive. Fixed with awindowcapture-phasekeydownsafety handler that detects stale key intercept (active but no.vim-motions-table-nav-modein 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(setKeyInterceptActiveandclearCursorSuppressedForViewexposed on bridge) - Plugin:
src/vim/table-nav-controller.ts(forceTableNavCleanup()export,logTableEventlifecycle logging) - Plugin:
src/vim/table-debug-state.ts(event log:logTableEvent,getTableEventLog,recentEventsin state output,mainCursorLayerHiddenfield)
- Plugin:
- 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: visiblewhich prevented horizontal scrolling entirely — no ancestor element was scrollable. Fixed by changing the table widget’s overflow tooverflow-x: autoduring table-nav mode and scrolling the widget element directly inscrollHighlightedCellIntoView(). (#167)- Plugin:
src/vim/table-nav-controller.ts(horizontal scroll logic targetings.widgetEl) - Styles:
styles.css(.vim-motions-table-nav-mode:overflow-x: auto; overflow-y: visible)
- Plugin:
- 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/luaWatchPathwere set to null becausefoundwas derived from the read result, not the stat. This broke both the file watcher (never fires) and the reload command (softReloadVimrcbails on null path). Three fixes: (1) watch paths are now resolved viaresolveVimrcPath/resolveLuaConfigPath(stat-based) as a fallback when the read-based load returnsfound: false; (2)softReloadVimrcno longer bails on empty commands — an empty parse is a valid reload (clears all vimrc mappings); (3)reloadAllConfigsresolvesvimrcWatchPathon-demand when null. (#168)- Plugin:
src/main.ts(stat-based watch path fallback in initial load,softReloadVimrcguard relaxed,reloadAllConfigson-demand resolution)
- Plugin:
- Removed Lua keymaps persist after config reload — keymaps registered via
vim.keymap.set()ininit.luawere not cleaned up when removed from the config and reloaded. Two root causes: (1)softReloadLuaConfigdid not unmap old Lua keymaps or undefine old ex commands before re-loading; (2) Lua keymaps were double-registered (eagerly duringloadInitLuaand again viaapplyLuaMapsinreloadFeatures), so a singlevim.unmaponly removed one entry. Fixed by adding an unmap loop insoftReloadLuaConfigthat removes all occurrences of each old mapping. (#168)- Plugin:
src/main.ts(softReloadLuaConfigunmap/undefineEx cleanup with multi-occurrence loop)
- Plugin:
- 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=999for centered scrolling), the viewport jumped while the mouse was still well within the editor. Fixed by tracking pointer-down state via a CM6 ViewPlugin witheventObserversand 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(addedmouseTrackerViewPlugin,pointerdown/pointerupobservers, document-levelpointerupsafety listener, 100ms settle timer)
- Plugin:
- Subword motion
dwdeletes across line boundaries — with subword motions enabled,dwon 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 motionmapCommandcalls were missingforward: true/forward: falsein motionArgs, so the fork’sclipToLine(which preventsdwfrom 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.), causingwto 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(addedforward: truetow/eandforward: falsetob/gesubword motion registrations) - Plugin:
src/motions/subword.ts(added punctuation group boundary detection viaPUNCT_GROUP_RE, merged with subword boundaries ingetLineStarts/getLineEnds)
- Plugin:
- Hover tooltips from other plugins clipped within scrolloff zone — hover tooltips (page preview, auto-linker, LanguageTool, and any CM6
hoverTooltipextension) were hidden for text within the top/bottom scrolloff lines. Root cause: the scrolloff implementation usedEditorView.scrollMargins, which CM6’s tooltip plugin reads to determine the “visible” viewport area — tooltips positioned within the margin zone were clipped totop: -10000px. With the default scrolloff of 5, the first ~5 lines were affected; withscrolloff=999(centered scrolling), the entire top half of the viewport was affected.mousedownwas unaffected because the mouse-down guard already returnednullfrom scroll margins. Fixed by replacingEditorView.scrollMarginswith anEditorView.updateListenerthat manually adjustsscrollDOM.scrollTopafter cursor movement, preserving the scrolloff scroll behavior without marking the margin zone as invisible to the tooltip system. (#170)- Plugin:
src/vim/scrolloff.ts(replacedscrollMarginsfacet withupdateListener-based manual scroll adjustment, added 100ms settle delay after pointerup to prevent scroll drift on mouse selection end)
- Plugin:
Tests
- 3 e2e tests in
test/specs/table-cursor-vanish.e2e.tsfor #167 (stuck state detection, bridge cleanup, automatic keydown safety handler) - 2 e2e tests in
test/specs/table-debug-state.e2e.tsfor table debug state instrumentation (#167) - 3 additional e2e tests in
test/specs/config-management.e2e.tsfor #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.tsfor #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.tsfor #174 (dwat end of line,dwon last word,dwbefore trailing punctuation) - 6 e2e tests in
test/specs/cursor-pointer-events.e2e.tsfor #170 (pointer-events, mousemove targeting, Decoration.mark hit-testing, caretRangeFromPoint, mouseover, hoverTooltip callback)
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: added horizontal scrolling fix, key intercept stuck limitation, table debug state documentationCONTRIBUTING.md: addedtable-debug-state.tsto file tree, updatedtable-nav-controller.tsandtable-cell-motions.tsdescriptions, updatedscrolloff.tsdescription to reflect new implementationAGENTS.md: updated fork API surface with new diagnostic exportsdocs/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)
- Plugin:
- 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(addedreload-configurationandopen-configurationcommands,reloadAllConfigs()andopenConfigInDefaultEditor()methods)
Fixed
reload-configurationfails when initial vimrc load missed the file — if the initial vimrc load hit the known adapter timing race (empty file read during early lifecycle),vimrcWatchPathwas null and the reload command silently did nothing. Fixed by resolving the vimrc path on-demand inreloadAllConfigs()whenvimrcWatchPathis null. (#168)- Plugin:
src/main.ts(reloadAllConfigs()path resolution fallback)
- Plugin:
reload-configurationnotification shown when notifications disabled — the generic “configuration reloaded” notice was shown whenshowConfigNotificationswas disabled, and was also shown whenconfigModewassettings(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)
- Plugin:
- Non-markdown files missing from
:files,:buffers, and buffer navigation —:filesonly listed.mdfiles (usedgetMarkdownFiles()),:buffersonly listed markdown leaves (checkedgetViewType() === 'markdown'), and]b/[b,:b <name>,:find,:bfirst/:blastall had the same markdown-only filters. Canvas, base, and other file-backed views were invisible to all buffer/file commands. Fixed by replacinggetMarkdownFiles()withgetFiles()for file listing,getViewType()checks withinstanceof FileViewfor leaf iteration, and addingleaf.getRoot() === rootSplitguards 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+rootSplitguard) - Plugin:
src/workspace/commands.ts(:buffersfallback modal,:find,:b,:bfirst/:blast— sameFileView+rootSplitpattern) - Plugin:
src/ui/global-ex-command.ts(global ex fallbacks:findFile,bufferSwitch,bufferFirstLast) - Plugin:
src/motions/buffers.ts(renamedgetMarkdownLeaves→getFileLeaves,instanceof FileView+rootSplit, removed.endsWith('.md')fallback filter)
- Plugin:
Tests
- 6 e2e tests in
test/specs/config-management.e2e.tsfor #168 (vimrc reload picks up new mappings; reloaded mapping works functionally; Lua config reload via command;open-configurationregistered;reload-configurationregistered;open-configurationdoes not throw) - 6 regression tests in
test/specs/files-buffers-non-md.e2e.tsfor #169 (:filespicker includes.canvasfiles;:bufferspicker includes canvas leaves;:findnavigates to canvas file;:bswitches to canvas leaf;:blastworks with canvas leaf present;]breaches canvas leaf)
Documentation
CHANGELOG.mdAGENTS.md: updated with configuration management commands and Lua hot-reloadCONTRIBUTING.md: updatedsrc/main.tsdescriptionREADME.md: added hot-reload to feature listdocs/configuration/lua-config.md: documented soft-reload and external editor commanddocs/configuration/vimrc.md: documented reload and external editor commandsdocs/reference/keybindings.md: added new configuration commands to Obsidian commands tabledocs/features/quality-of-life.md: added configuration hot-reload sectiondocs/features/index.md: added hot-reload to configuration overviewdocs/index.md: added hot-reload to feature highlightsdocs/features/ex-commands.md: clarified:filessearches all vault files, not just markdown
[0.144.0] - 2026-09-04
Added
- Lua
oslibrary support — theoslibrary 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.executeandos.exitare permanently blocked for security.- Plugin:
src/lua/engine.ts(openedoslibrary, added defense-in-depth blocks)
- Plugin:
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 betweendefs.ts/luaconf.tsandlinit.ts/lualib.ts. Implemented a hybrid TValue type system with type predicates and narrowed accessors.- Plugin:
src/lib/fengari/*.ts(37 files converted from.jsCJS to.tsESM) - Plugin:
src/lib/fengari/index.ts(barrel rewritten — noas anycasts) - 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 forluaL_newstate()) - Plugin:
vitest.config.ts(removed.test.jspattern) - 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.warnfor Luaprint()fallback — dead code in plugin context, avoids community scannerno-consolewarning) - Plugin:
src/lua/engine.ts(narrowedlua_atnativeerrorhandler string coercion)
- Plugin:
- 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)
- Plugin:
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.tsfor theoslibrary
Documentation
CHANGELOG.mdAGENTS.md: updated fengari section — TypeScript ESM conversion, TValue type system, test organizationCONTRIBUTING.md: updated fengari directory descriptionKNOWN_LIMITATIONS.md: updated absorbed fengari references and linksdocs/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 byregisterWorkspaceNavigation()— 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()andregisterWorkspaceNavigation()were both inside theif (enableWorkspaceNav)gate. Fixed by (1) movingregisterExCommands()outside the gate, (2) splittingregisterWorkspaceNavigation()intoregisterCoreVimActions()(always called — editor commands unrelated to pane/tab management) andregisterWorkspaceNavigation()(gated — pane focus, tab switching, splits, close). (#165)- Plugin:
src/main.ts(registerExCommandsandregisterCoreVimActionscalled unconditionally;registerWorkspaceNavigationremains gated) - Plugin:
src/workspace/navigation.ts(split intoregisterCoreVimActions+registerWorkspaceNavigation)
- Plugin:
- 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()andregisterFoldEnableCommands()were called insideregisterOperators(), which is gated byenableHardWrap. Fixed by moving both fold registration calls out ofregisterOperators()and calling them unconditionally inmain.ts.- Plugin:
src/operators/register.ts(removed fold registration calls) - Plugin:
src/main.ts(registerFoldEnableCommandsandregisterFoldCommandscalled unconditionally)
- Plugin:
keyToKeymappings silently fail when prefix-deferred —vim.keymap.set("n", "<leader><leader>", ":buffers<CR>")withvim.g.mapleader = " "silently did nothing because<Space><Space>h(hint mode) created a partial match, triggering the fork’s prefix-ambiguity deferral. After theoperatorshadowtimeout(1000ms), the deferred command timer calledcommandDispatcher.processCommand()which has no handler forkeyToKeytype mappings — they fell through todefault: break. Fixed by adding akeyToKeytype check in the deferred command timer that routes todoKeyToKey()instead, matching the dispatch logic in the mainfindKeypath. The 1-second delay before the deferred command fires is correct Neovimtimeoutlenbehavior — users who want a shorter delay can useset timeoutlen=300(now a supported alias foroperatorshadowtimeout). (#166)- Fork:
~/Repos/codemirror-vim/src/vim.js(_deferredCommandTimercallback:keyToKey→doKeyToKey;timeoutlen/tmregistered as aliases foroperatorshadowtimeout;timeoutlen=0executes immediately instead of blocking) - Plugin:
src/vim/neovim-options.ts(removedtimeoutlen/tmfrom noop list — now handled by the fork) - Plugin:
src/vimrc/loader.ts(addedtimeoutlen/tmtoKNOWN_SET_OPTIONSas aliases foroperatorshadowtimeout) - Plugin:
src/lua/api.ts(vim.opt.__newindexnow syncs non-sideEffectKNOWN_SET_OPTIONSto the fork viacallbacks.setOption) - Plugin:
src/ui/which-key.ts(suppressvim-keypressaftervim-command-doneto prevent which-key popup re-showing after synchronous command execution)
- Fork:
Tests
- 3 regression tests in
test/specs/settings-reload.e2e.tsfor #165 (:buffersex command works with workspace nav disabled;gOoutline works with workspace nav disabled;zjfold motion works with hard-wrap disabled) - 2 regression tests in
test/specs/settings-reload.e2e.tsfor #166 (<Space><Space>keyToKey mapping executes after operatorshadowtimeout when<Space><Space>hpartial exists;timeoutlen=0executes deferred mapping immediately)
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: marked workspace navigation paste dependency as fixed; updated operator-prefix key dispatch section withtimeoutlen/tmaliases,keyToKeydispatch fix, andtimeoutlen=0immediate executionCONTRIBUTING.md: updatednavigation.tsandoperators/register.tsdescriptions to reflect splitAGENTS.md: updated fork description withkeyToKeydeferred command fix andtimeoutlen/tmaliasesdocs/configuration/settings.md: updatedoperatorshadowtimeoutdescription withtimeoutlen/tmaliasesdocs/configuration/vimrc.md: updatedoperatorshadowtimeoutshort names withtimeoutlen/tmaliasesdocs/configuration/lua-config.md: updatedoperatorshadowtimeoutentry withtimeoutlen/tmaliases
[0.142.0] - 2026-09-03
Fixed
:obcommandvia 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) viaexmap+vmap, the command operates on only the cursor line instead of all selected lines. Root cause: the codemirror-vim fork’sExCommandDispatcher._processCommand()exits visual mode before executing the ex command handler, so by the time:obcommandcallsexecuteCommandById(), the CM6 selection is collapsed. Fixed by reading the visual range fromparams.selectionLine/params.selectionLineEnd(set byparseInput_beforeexitVisualMode) and falling back to the'</'>vim marks for theexmapindirection path where the inner_processCommandno 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(addedgetVisualRange()andexpandSelectionFromRange()increateObCommand) - Plugin:
src/types/vim-api.d.ts(addedselectionLine/selectionLineEndtoExCommandArgs)
- Plugin:
Tests
- 3 e2e tests in
test/specs/obcommand-visual-mode.e2e.tsfor #161 (directhandleExbullet toggle,defineExexmap indirection, numbered list variant — all in visual-line mode)
Documentation
CHANGELOG.mdKNOWN_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.mtsbrowserVersionchanged 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.setmappings, and hint mode global hotkeys. Root cause: theGlobalKeyHandlerandGlobalMappingRegistrywere only instantiated whenenableWorkspaceNavwas 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 theenableWorkspaceNavguard 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(removedenableWorkspaceNavguard fromshouldIntercept*methods) - Plugin:
src/workspace/global-defaults.ts(addedopts.enableWorkspaceNavparameter to conditionally register workspace-nav-specific keys) - Plugin:
src/main.ts(always createGlobalKeyHandler/GlobalMappingRegistryon desktop)
- Plugin:
- Hint mode
<leader><leader>hmapping reappears after settings change —vim.keymap.del("n", "<leader><leader>h")in init.lua was silently undone by any subsequent settings change becausereloadFeatures()re-registered the hardcoded mapping without re-applying Lua map operations. Additionally, the mapping was registered without an explicitcontext: 'normal', making mode-specificvim.keymap.delunable to target it (codemirror-vim’sunmaprequires exact context match). Fixed by (1) callingapplyLuaMaps()at the end ofreloadFeatures()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(applyLuaMapscall inreloadFeatures,context: 'normal'on hint modemapCommand)
- Plugin:
- Leader-based
mapCommandcalls missingcontext: '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 madevim.keymap.del("n", ...)unable to remove them because codemirror-vim’sunmap(lhs, 'normal')requirescontext === 'normal'to match. Added{ context: 'normal' }to all leader-based actionmapCommandcalls. 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/raandgrn/grr/gramappings)
- Plugin:
vim.keymap.delunable to remove context-less mappings per-mode —Vim.unmap(lhs, 'normal')silently failed on mappings created bymapCommand()or:mapwithout a mode prefix, because the entry hadcontext === undefinedand the strict equality check (undefined === 'normal') never matched. The only removal paths wereremoveMapCommand(keys)orunmap(lhs, undefined), both of which remove from all modes. Fixed in the fork:unmapnow 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:nunmapon a:map-created mapping. Also fixedmapclear’s context-splitting mode set from['normal', 'insert', 'visual']to['normal', 'visual', 'operatorPending']to match Neovim’s:mapsemantics (:mapcovers normal+visual+operatorPending, not insert).- Fork:
~/Repos/codemirror-vim/src/vim.js(unmapcontext-less entry splitting,mapclearmode set fix)
- Fork:
- Command palette lags ~1 second in visual-line mode — opening the command palette from visual-line mode caused a visible delay. Root cause: the
wrapCheckCallbackwrapper calledwithExpandedSelectionfor every command’scheckCallback(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 skippingwithExpandedSelectionfor the checking path (checkCallback(true)) — theVisualLineSomethingSelectedPatchalready ensuressomethingSelected(),getCursor(), andlistSelections()return the correct visual-line range, so availability checks work without native CM6 selection expansion. The executing path (checkCallback(false)) still useswithExpandedSelection. (regression of #157, #163)- Plugin:
src/vim/visual-line-command-fix.ts(skipwithExpandedSelectionforcheckCallback(true))
- Plugin:
- Stale visual-line selection cache causes RangeError —
lastVisualLineSelandpendingVisualLineSelcached 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:ecommands). The stale cache causeddoc.line(N)to throwRangeError: 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(isSelInBoundsvalidation insomethingSelected,getSelection,replaceSelection)
- Plugin:
Tests
- 3 regression tests in
test/specs/settings-reload.e2e.tsfor #164 (global key handler/registry existence with workspace nav disabled) and #162 (Lua keymap deletions survivereloadFeatures)
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: updated workspace navigation section to reflect decoupled architecture; updated yank-ring workspace navigation dependency note; updated three-gate description; updatedkeymap.del + Qdeviation andremoveMapCommanddescriptionKNOWN_LIMITATIONS.md: updatedwrapCheckCallbackdescription withcheckCallback(true)optimization (#163)AGENTS.md: updated fork description withunmapper-mode splitting andmapclearmode set fix- Fork:
~/Repos/codemirror-vim/DIFFERENCES.md: added “Per-modeunmapof context-less mappings” section; updatedremoveMapCommanddescription
[0.140.0] - 2026-09-03
Fixed
- Picker
pick_keymapignores Alt and other modifier keys —vim.obsidian.pick_keymap()only recognized theC-(Ctrl) modifier prefix. Key specs withA-(Alt),S-(Shift),M-(Meta), or modifier combinations likeC-A-jwere silently ignored. Root cause:matchesPickerKey()had a simpleC-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 correspondingKeyboardEventflags. (#159)- Plugin:
src/picker/types.ts(rewrotematchesPickerKeywith full modifier support)
- Plugin:
- 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(rewrotenormalizeKeyEventwith full modifier prefix generation)
- Plugin:
- Hint mode missing
altKeyin result —waitForHintKey()capturedctrlKey,metaKey, andshiftKeyfrom the label selection event but discardedaltKey. AddedaltKeyto theHintResultinterface and all three resolve sites, making Alt-modified hint actions possible for future extensions.- Plugin:
src/ui/hint-mode.ts(addedaltKeytoHintResultinterface and all resolve sites)
- Plugin:
- Subword motions skip non-ASCII Unicode characters —
w/b/e/gewith 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]/andSUBWORD_REonly 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-awareSUBWORD_REandisWordChar()) - Plugin:
src/motions/subword.ts(Unicode-awareWORD_SEGMENT_REfallback)
- Plugin:
- EasyMotion word targets skip non-ASCII Unicode characters — EasyMotion
w/b/eword motions did not generate jump labels on Arabic, CJK, or other non-ASCII words. Root cause:WORD_START_REused\b\wandWORD_CHARS_REused\w+, both ASCII-only. Fixed with[\p{L}\p{M}\p{N}]+using theuflag. (#160)- Plugin:
src/easymotion/targets.ts(Unicode-awareWORD_START_REandWORD_CHARS_RE)
- Plugin:
Tests
- 29 unit tests in
test/unit/picker/picker-keymap.test.tsformatchesPickerKeymodifier support (Alt, Shift, Meta, combinations, multi-spec matching) (#159) - 4 e2e tests in
test/specs/picker-modifier-keys.e2e.tsfor Alt-j/Alt-k picker navigation viapick_keymapLua config (#159) - 21 unit tests in
test/unit/global-mapping-registry.test.tsfornormalizeKeyEventmodifier normalization (Ctrl, Alt, Meta, Shift, special keys, combinations) - 6 unit test cases in
test/unit/subword.test.tsfor Unicode boundary/end detection (Arabic, mixed ASCII+Arabic, CJK, accented Latin) - 6 regression tests in
test/specs/subword-motions.e2e.tsfor Unicode subword motions (w/b/e/dwon Arabic, mixed text, CJK) (#160) - 5 regression tests in
test/specs/easymotion-comprehensive.e2e.tsfor Unicode EasyMotion word targets (w/e/bon Arabic and CJK) (#160)
Documentation
CHANGELOG.mddocs/features/ex-commands.md: updated picker key format description to documentA-,S-,M-modifier prefixes and combinationsdocs/configuration/remapping.md: updated picker key format description and added Alt-j/Alt-k exampleREADME.md: updated subword motions description to mention Unicode supportdocs/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 tolua/, and manages a lock file (lua/.plugin-lock.json) for version pinning. Supports branch, tag, and commit pinning. Atomic staging writes tolua/.staging/ensure vault integrity. Configurable viapluginAutoFetchsetting (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(extendedvim.plugins.addwith fetch triggering) - Plugin:
src/lua/loader.ts(wired fetch callback) - Plugin:
src/settings.ts(newpluginAutoFetchsetting)
- Plugin:
require()init.lua fallback —require("name")now trieslua/name/init.luaiflua/name.luais not found. Matches Neovim’s module resolution behavior and enables multi-file plugins.- Plugin:
src/lua/package.ts(updatedpackage.pathand search logic)
- Plugin:
Changed
- Lua sandbox:
rawget/rawset/rawequalre-enabled — these standard Lua functions are now available in the sandbox, matching Neovim’s Lua environment. Previously disabled as a sandboxing measure, but this preventedvim.is_callablefrom detecting callable tables and broke compatibility with Neovim plugins that userawget/rawset.- Plugin:
src/lua/engine.ts(removed from disabled globals list)
- Plugin:
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)
- Plugin:
- Fork: Backtracking dispatch — the codemirror-vim fork now supports backtracking when a longer partial match fails. Deferring a shorter full match (e.g., flash
smotion) for a longer partial (e.g., surrounds<char>) no longer loses the motion if the partial match fails or times out. Replays the suffix viadoKeyToKey.- Fork:
~/Repos/codemirror-vim/src/vim.js(backtracking logic inhandleKeyNonInsertMode)
- Fork:
- Fork: Backtracking deferral exclusion rules — the backtracking deferral in
matchCommandno 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:_isDefaultentries (built-in keymaps), special-key false prefix (<vs<leader>), motions extending non-motions (ilvsi), andoperatorPendingactions without an active operator (s<char>vss). Without this fix, pressingiorain normal mode was deferred by ~1 second instead of immediately entering insert mode.- Fork:
~/Repos/codemirror-vim/src/vim.js(exclusion rules inmatchCommand) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(documented all 4 exclusion rules)
- Fork:
- Lua function-callback keymaps survive view plugin recreation —
applyLuaMapsreverted to usethis.registration?.defineAction()instead ofvim.defineAction()directly. TheVimRegistrationpersistence layer re-applies action registrations when the CM6 view plugin is recreated (e.g., during vim mode toggle), preventing Luavim.keymap.setfunction callbacks from silently stopping.- Plugin:
src/main.ts(revertedapplyLuaMapsfunction-callback path)
- Plugin:
Tests
- CI test plugin pre-fetch — e2e tests no longer depend on runtime GitHub fetches. A
scripts/fetch-test-plugins.shscript downloads plugin tarballs before tests run, driven bytest/fixtures/test-plugins.json. The CI workflow fetches plugins in a dedicated step on all platforms (Linux, macOS, Windows).loadMiniCommentskipsvim.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—loadMiniCommentdetects pre-fetched plugins and skipsvim.plugins.add(); fetch test guarded withthis.skip()when pre-fetched. - Added
pluginAutoFetchto excluded settings intest/unit/known-set-options.test.ts. - Added
vimHandleKeysSynchelper intest/helpers.ts— dispatches keys viaVim.handleKeyin a single synchronousexecuteObsidiancall with Escape prefix. SupportswaitForTimeoutflag for leader-key mappings that trigger Neovim-correctoperatorshadowtimeoutdeferral. - Updated
test/specs/lua-vim-v.e2e.tsto usevimHandleKeysSyncwithwaitForTimeout=truefor<leader>t/<leader>hmappings that have longer partials (table nav keymaps\tL,\tdd, etc.). - Updated
test/specs/lua-doc-examples.e2e.tssmart-go-to-top test to usevimHandleKeysSyncwithwaitForTimeout=true. - Updated
test/specs/lua-require.e2e.tsrawget/rawset tests — expectations changed from 86/87 to 90 to reflect intentional re-enabling.
Documentation
CHANGELOG.mdAGENTS.md: addedvimHandleKeysSyncto test helpers list, added fork backtracking description, added CI test plugin pre-fetch descriptionCONTRIBUTING.md: updatedsrc/lua/file tree with new fetch-related files, added test plugin pre-fetch documentationKNOWN_LIMITATIONS.md: marked plugin fetching as implemented; addedinit.luafallback note; updatedvim.is_callablenote (rawget no longer restricted); updated operator-prefix key dispatch section with backtracking deferralREADME.md: added plugin auto-fetch to features listdocs/configuration/settings.md: addedpluginAutoFetchsettingdocs/configuration/lua-config.md: updatedvim.plugins.addandrequire()sections
[0.138.0] - 2026-09-02
Added
- Tier 1
vim.apiexpansion — 27 newnvim_*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,requireWindowZerovalidator, updated metatable) - Plugin:
src/lua/loader.ts(8 new callbacks:getCmAdapter,getMarkPos,setMark,delMark,getLine,setLine,replaceRange)
- Cursor + line + marks:
Fixed
- Table-nav
Tab/Shift+Tabstops at row boundaries — pressingTabat the last cell of a row now wraps to the first cell of the next row, andShift+Tabat 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. Thenavigate()method gains awrapparameter;h/lkeys retain their non-wrapping behavior. (#158)- Plugin:
src/vim/table-nav-controller.ts(wrapping logic innavigate(), forwardedwrapparameter in actions lambda and cell-edit scope) - Plugin:
src/vim/table-nav-keymap.ts(wrapparameter inTableNavActionsinterface, Tab handler passeswrap=true)
- Plugin:
Tests
- 29 new unit tests in
test/unit/lua/api.test.tsfor all newnvim_*functions (92 total) - 4 regression tests in
test/specs/table-nav-mode.e2e.tsfor Tab/Shift+Tab row wrapping (#158)
Documentation
CHANGELOG.mdAGENTS.md: updatedvim.apifunction count to 43 with expanded category listCONTRIBUTING.md: updatedsrc/lua/api.tsdescription and buffer/autocmd/highlight file descriptionsKNOWN_LIMITATIONS.md: 6 new known limitations for Lua API (character offsets, deprecated options, feedkeys flags, echo, handles, replace_termcodes)README.md: updatedvim.api.*descriptiondocs/configuration/lua-config.md: full API reference tables for all new functionsdocs/features/tables.md: updated Tab/Shift+Tab description to mention row wrappingdocs/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.treesitterAPI — full treesitter integration backed byweb-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 callsget_parser(). Provides the complete Neovimvim.treesitterAPI 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
- Core:
- Treesitter-enhanced Markdown features — core plugin features now use treesitter for structural parsing when available, with regex fallback:
- Fold provider: treesitter
sectionnode 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 nesteddiBscoping) - 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
- Fold provider: treesitter
- 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)
- Plugin:
- 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
- Position lookup:
- Fork:
setTokenClassifierhook — codemirror-vim fork now exposesVim.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)
- Fork:
Changed
- Fold golden tests re-recorded — fold-motions golden data re-recorded with
vim.treesitter.foldexpr()active andluaSetupforcing synchronous treesitter parse + fold recomputation before each test case. Corrected2zkgolden value that was affected bynvim_feedkeysasync timing (verified againstnormal!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')andlistSelections()to determine the replacement range, but these returned a collapsed cursor-only range in visual-line mode — the text was read correctly viagetSelection()but the replacement was inserted at a zero-width cursor position without removing the original lines. (2) ThewithExpandedSelectionwrapper’sfinallyblock restored cursor-only CM6 selection before the async modal completion callback ran, andhandleExternalSelectionin codemirror-vim exited visual mode when it saw the collapsed selection. Fixed by patchinggetCursor('from'/'to')andlistSelections()to return the visual-line expanded range; adding apendingVisualLineSelWeakMap snapshot set beforeexpandSelection()that survives async modal flows (30 s TTL, consumed on first use byreplaceSelection); and extending the fallback chain insomethingSelected(),getSelection(), andreplaceSelection()to includelastVisualLineSelandpendingVisualLineSel. (#157, regression of #137/#138)- Plugin:
src/vim/visual-line-command-fix.ts(pendingVisualLineSelWeakMap snapshot;getCursor/listSelectionspatches;lastVisualLineSelfallback chain insomethingSelected/getSelection/replaceSelection)
- Plugin:
zj/zkskip nested heading folds —zjnow visits all foldable lines including nested headings (e.g.,## Headinginside a# Headingsection), matching Neovim behavior. PreviouslyfindNextFoldable/findPrevFoldablehad aparentRangefilter 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(removedparentRangefilter fromfoldNext/foldPrev, fixedfoldPrevcount search to use fold start instead of fold end)
- Plugin:
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(withluaSetupforcing treesitter parse before fold motions) - 6 fold motion Neovim deviations removed from
test/neovim/deviations.ts
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: updated visual-line selection section withgetCursor/listSelectionspatches,pendingVisualLineSelWeakMap snapshot, and fallback chain description (#157); updated test coverage count (4 → 6 tests); updated trade-off section to list all 5 patched Editor methodsAGENTS.md: treesitter architecture, new file descriptions, Lua API surface update, forksetTokenClassifierhookCONTRIBUTING.md: treesitter file tree insrc/structure,tree-state.tsaddedKNOWN_LIMITATIONS.md: treesitter integration section with 7 known limitationsREADME.md: treesitter feature in Lua configuration descriptiondocs/configuration/lua-config.md: updated API availability note
[0.136.0] - 2026-09-01
Added
- 36 new
vim.fnfunctions — expanded the Lua API from 29 to 65 supportedvim.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("']")enablesg@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 +VimFnCallbacksextended withsetCursor,getMarkPos,setMark,setLine,insertLines) - Plugin:
src/main.ts(wired new callbacks inexecuteLuaForTest) - Plugin:
src/lua/loader.ts(wired new callbacks in main loader)
- Register:
- Neovim-compatible
foldopenoption — fold-aware navigation now matches Neovim’sfoldopensemantics. Each vim motion is tagged with a category (hor,block,jump,mark,search,percent,undo), and only motions whose category is in the configuredfoldopenset 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 viaset foldopen=block,hor,mark,percent,search,undo(Neovim default) orset fdo=all. (#155)- Fork:
~/Repos/codemirror-vim/src/cm_adapter.ts(foldopenAnnotationCM6 transaction annotation,_pendingFoldopenon adapter) - Fork:
~/Repos/codemirror-vim/src/vim.js(40+ motions tagged withfoldopencategory indefaultKeymap, annotation set inevalInput,undo/redo,jumpListWalk) - Fork:
~/Repos/codemirror-vim/src/types.ts(FoldopenCategorytype,foldopenonMotionArgsPartial) - Fork:
~/Repos/codemirror-vim/src/index.ts(exportedfoldopenAnnotation,FoldopenCategory) - Plugin:
src/vim/fold-sync.ts(annotation-gatedfoldAwareNavExtender,setFoldopen()/getFoldopen()/shouldUnfold()) - Plugin:
src/vim/bundled-vim.ts(injects fork annotation at extension creation) - Plugin:
src/motions/register.ts(structural motions taggedblock, subword motions taggedhor) - Plugin:
src/vim/options.ts(set foldopen=…/set fdo=…vim option) - Plugin:
src/main.ts(foldopenhandler inapplySettingOverride) - Plugin:
src/settings.ts(updated fold-aware navigation descriptions in both settings tabs)
- Fork:
- 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/:foldcloseonly operated at the cursor.:{range}foldcreates a manual fold;!suffix opens/closes recursively;:folddoopen/:folddoclosedexecute 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(removedexCommandFromActionregistrations for:foldopen/:foldclose/:foldtoggle— replaced by range-aware versions infold/commands.ts) - Plugin:
src/main.ts(executeLuaForTestsandboxhandleExCommandwired tovim.handleEx— enables golden tests to create folds viavim.cmd)
- Plugin:
- Neovim options registry — every Neovim option (378 total from
src/nvim/options.lua) is now recognized by name in both:set(vimrc) andvim.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=awarns,set mouse=ais silent).- Plugin:
src/vim/neovim-options.ts(new file — comprehensive registry with tier classification) - Plugin:
src/vimrc/loader.ts(integrated registry, replacedKNOWN_CM_VIM_OPTIONSset, tiered logging) - Plugin:
src/lua/api.ts(integrated registry intovim.opt/vim.ogetter and setter)
- Plugin:
- 12 configurable Neovim options — standard Neovim options previously hardcoded in the codemirror-vim fork are now user-configurable via
set/vim.optwith 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 —:sg flag inverts when on) - Motion:
startofline/sol(default on),whichwrap/ww(defaultb,s),virtualedit/ve(default empty — supportsonemore,all,block) - Editing:
joinspaces/js(default off —Jinserts double space after.!?),shiftround/sr(default off —>>/<<round to shiftwidth),nrformats/nf(defaultbin,hex—<C-a>/<C-x>format support, octal now available) - Fork:
~/Repos/codemirror-vim/src/vim.js(12defineOption()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 inKNOWN_SET_OPTIONSwith_fork:prefix settingsKey)
- Search:
- Oil preview window (
<C-p>) — toggle a side-by-side preview split showing the file under the cursor. Auto-updates on cursor movement viaEditorView.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 +oilPreviewaction) - Plugin:
src/oil/oil-view.ts(<C-p>scope key registration)
- Plugin:
- 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 visualselectbehavior.- Plugin:
src/oil/manager.ts(getVisualRangeEntries(),openMultipleEntries())
- Plugin:
- Oil hidden toggle guard —
g.toggle is now blocked when the oil buffer has unsaved changes, showing a notice instead. Matches oil.nvim’stoggle_hidden()behavior which warns and refuses when modified buffers exist.- Plugin:
src/oil/manager.ts(hasUnsavedChanges()guard intoggleHidden(), return type changed toboolean) - Plugin:
src/oil/keybindings.ts(checks return value before refreshing)
- Plugin:
Fixed
- Oil: unable to create files with a new directory — creating a file like
newfolder/notes.mdwherenewfolder/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 (unlikeapp.vault.createFolder()). (#154)- Plugin:
src/oil/actions.ts(ensureParentDirs()— walks path segments and creates missing directories before file creation)
- Plugin:
Tests
- Oil nested path creation e2e tests — 3 new e2e tests in
test/specs/oil-poc.e2e.tsverifying 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.tsfor 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 inex-commands-expandedandex-globalsuites) - 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)
- Definitions:
:dcount/address and:jrange 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 fromargs[0];:$dresolves via the fork’sparseLineSpec_;:jrange fix changesrepeat: lineEnd - linetorepeat: lineEnd - line + 1;:j!now passeskeepSpaces: trueviaparams.argStringbang detection. All 4 deviations removed.- Fork:
~/Repos/codemirror-vim/src/vim.js(exCommands.join: off-by-one fix +keepSpacesbang support) - Deviations:
test/neovim/deviations.ts(2upstream-unsupportedentries removed::d3,:$d)
- Fork:
- Foldopen unit tests — 36 tests covering
setFoldopen()parsing,shouldUnfold()for every individual category,all/empty sets, custom combinations, and backward-compatiblesetFoldAwareNavigation().- Test:
test/unit/foldopen.test.ts
- Test:
- Foldopen golden tests — 8 golden cases recorded against Neovim 0.12.5 covering
j/k/3j/G/gg/5Gwith 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(newfoldopensuite) - Deviations:
test/neovim/deviations.ts(1 infra-limitation entry for column difference)
- Test:
vim.fne2e tests — 35 tests intest/specs/lua-vim-fn.e2e.tscovering all newvim.fnfunctions 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
- Test:
- Fold-aware navigation e2e tests — expanded from 2 to 6 tests. New tests verify
j/kdo not auto-open folds,jfrom fold line skips past the fold,knavigates back to fold line, and upwardktraversal preserves all folds.- Test:
test/specs/fold-navigation.e2e.ts
- Test:
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: updatedvim.fnfunction count (27 → 65), added not-yet-implemented table (lower-value/niche functions), added explicitly excluded functions (map/filter/printfincompatible 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:oilpreviewto oil keybindings list; updated oil status lineREADME.md: oil explorer description updated with<C-p>preview, visual mode multi-select, nested path creationdocs/reference/keybindings.md: added<C-p>(:oilpreview) andg?(:oilhelp) to oil keybindings tabledocs/features/oil-explorer.md: added preview, visual mode multi-select, nested path creation, and hidden toggle guard sections; added:oilpreviewto ex commands tabledocs/features/ex-commands.md: added:oilpreviewand:oilhelpto oil ex commands tabledocs/configuration/lua-config.md: added 36 newvim.fnfunctions to quick-reference and detailed tables; added 12 new Neovim options tovim.opttableAGENTS.md:vim.fncount updated (27 → 65), fork API surface updated withfoldopenAnnotationand 12 configurable Neovim optionsCONTRIBUTING.md:fn.tsdescription updated (26 → 65 functions); addedneovim-options.tstosrc/vim/file treedocs/development/architecture.md:fn.tsfunction count updated- Fork:
~/Repos/codemirror-vim/DIFFERENCES.md::joinrange/bang/cursor section expanded; added “Configurable Neovim options” section documenting 12 newdefineOption()calls README.md: folding feature description updated withfoldopensemantics; quality of life section updated with 12 configurable Neovim options and Neovim option compatibilitydocs/configuration/settings.md: fold-aware navigation description updated,foldopenrow added to Workspace navigation settingsdocs/configuration/vimrc.md:foldopen/fdoadded to string options table,foldawarenavigationdescription updated; added 12 new Neovim options to boolean and string tables; updated unknown options warning description for Neovim options registrydocs/configuration/lua-config.md:foldopenadded tovim.optoptions tabledocs/features/workspace-navigation.md: fold-aware navigation section rewritten forfoldopensemanticsdocs/features/ex-commands.md: added:{range}fold,:{range}foldopen[!],:{range}foldclose[!],:folddoopen,:folddoclosed; updated:foldopen/:foldclosedescriptions to note range support- Fork:
~/Repos/codemirror-vim/DIFFERENCES.md:foldopenAnnotationAPI 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, anddisable-vim-mode. Uses a mutable extension array for zero-latency switching. (#153)- Plugin:
src/main.ts(mutable extension array, toggle methods, Obsidian commands,vimEnabledguards) - Plugin:
src/settings.ts(newvimEnabledsetting in General page) - Fork:
~/Repos/codemirror-vim/src/index.ts(exportedresetForkedVimState()) - Fork:
~/Repos/codemirror-vim/src/block-cursor.ts(exportedresetCursorState())
- Plugin:
- Lua ex command cleanup — track and remove Lua-defined ex commands during toggle or reload.
- Plugin:
src/lua/loader.ts(ex command name tracking)
- Plugin:
Changed
- Vim subsystem lifecycle — extracted
setupVimSubsystems()andteardownVimSubsystems()for clean runtime toggling.- Plugin:
src/main.ts(lifecycle extraction)
- Plugin:
- Bundled vim state reset —
bundledActiveis now resettable to support runtime re-initialization.- Plugin:
src/vim/bundled-vim.ts(resettable state)
- Plugin:
Fixed
- Native cursor visibility after vim toggle —
BlockCursorPlugin.destroy()now correctly restores nativecaret-colorand cursor layer display, preventing an invisible cursor when vim is disabled.- Fork:
~/Repos/codemirror-vim/src/block-cursor.ts(restore logic indestroy())
- Fork:
this.register()accumulation — fixed a bug wherereloadFeatures()would accumulate listeners on every call.- Plugin:
src/main.ts(cleanup logic)
- Plugin:
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)
- Test:
- Mutable array spike — validated the
updateOptions()mechanism for runtime extension swapping.- Test:
test/specs/spikes/spike-mutable-array-destroy.e2e.ts(3 tests)
- Test:
- Regression detection — added a
beforeSuitehook towdio.conf.mtsthat cycles vim mode before every spec.- Test:
wdio.conf.mts(toggle-cycle hook)
- Test:
Documentation
CHANGELOG.mdAGENTS.md: updated fork API surface withresetForkedVimState,resetCursorState,BlockCursorPlugin.destroy()cursor restoration; documented vim toggle capability in dual-vim architecture; addedbeforeSuitetoggle-cycle hook and new test files to test infrastructure section; addedvimEnabledto settings documentationCONTRIBUTING.md: documentedsetupVimSubsystems()/teardownVimSubsystems()extraction pattern and mutable extension array mechanismREADME.md: added vim toggle commands to Quality of life featuresdocs/configuration/settings.md: addedvimEnabledsetting to General pagedocs/reference/keybindings.md: added Obsidian commands section withtoggle-vim-mode,enable-vim-mode,disable-vim-mode- Fork:
~/Repos/codemirror-vim/DIFFERENCES.md: documentedresetForkedVimStateAPI,resetCursorStateAPI, andBlockCursorPlugin.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, newregisterExCommands()public method) - Plugin:
src/main.ts(callregisterExCommands()at startup and settings reload)
- Plugin:
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.mddocs/features/oil-explorer.md: added note about guard notices for Oil ex commands outside Oildocs/features/ex-commands.md: added note about Oil ex command scopedocs/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 sharedshowTooltipfacet, was not repositioned because the tooltip layer’s initial geometry measurement was skipped during the editor mount/layout settling phase. Additionally, a standalonetooltips({ parent: document.body })extension conflicted with Obsidian’s own tooltip configuration. Fixed by: (1) removing the redundanthasFocusguard in the completion source, (2) removing the conflictingtooltips({ parent: document.body })extension, and (3) adding a MutationObserver-based ViewPlugin that detects stuck tooltips at-10000pxand force-positions them usingcoordsAtPos. (#151)- Plugin:
src/snippets/completion-source.ts(removedhasFocusguard) - Plugin:
src/main.ts(removedtooltips({ parent: document.body }), added tooltip positioning nudge ViewPlugin)
- Plugin:
Tests
- 1 new e2e spec file
test/specs/snippets/snippet-completion-menu.e2e.ts— creates a new file viaapp.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
globalConfigSearchsetting (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. UsesgetObsidianUserDataDir()(ElectronuserDatapath) for platform-correct resolution. (#150)- Plugin:
src/settings.ts(newglobalConfigSearchsetting, declarative + imperative UI,RELOAD_KEYS) - Plugin:
src/lua/loader.ts(resolveLuaConfigPathglobal search step,LoadInitLuaOptionsupdate) - Plugin:
src/vimrc/loader.ts(resolveVimrcPathglobal search step,loadVimrcsignature update) - Plugin:
src/main.ts(passesglobalConfigSearchto both loaders andsoftReloadVimrc)
- Plugin:
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 includessetOperatorfunc(fn)andgetOperatorfunc().- Fork:
~/Repos/codemirror-vim/src/vim.js(newoperatorfuncoperator,setOperatorfunc/getOperatorfuncAPI)
- Fork:
:moveand:copyex 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:moveand:copyex command implementations) - Plugin:
src/workspace/commands.ts(cursor positioning fix for:move)
- Fork:
- Insert mode undo control — added
<C-G>uto manually insert an undo break and<C-G>Uto suppress the next automatic undo break on cursor movement.- Fork:
~/Repos/codemirror-vim/src/vim.js(new<C-G>uand<C-G>Uactions)
- Fork:
- 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)
- Fork:
- 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)
- Fork:
- Lua
vim.o.operatorfuncbridge — wiredoperatorfuncthrough the Lua API. Users can assign a Lua function tovim.o.operatorfuncwhich will be called wheng@is used.- Plugin:
src/lua/api.ts(Lua bridge foroperatorfunc) - Plugin:
src/types/vim-api.d.ts(updatedVimApitype definition)
- Plugin:
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 inhandleEx, count support)
- Fork:
<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)
- Fork:
gRvirtual 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)
- Fork:
<C-a>re-insert previous insert — now correctly saves changes topreviousInsertModeChangesbefore resetting, ensuring the previous insert is always available.- Fork:
~/Repos/codemirror-vim/src/vim.js(recording fix inrecordLastEdit)
- Fork:
- 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)
- Test:
Fixed
:movecursor positioning — cursor now correctly lands on the last moved line instead of the first.- Plugin:
src/workspace/commands.ts(positioning fix)
- Plugin:
@:e2e test assertion — strengthened assertion fromtoContaintotoBefor more reliable verification.- Test:
test/specs/vim-builtin/new-commands.e2e.ts(assertion fix)
- Test:
- Documentation: incorrect shared config directory instructions — the “Shared config across vaults” sections in
lua-config.mdandvimrc.mdpresented global paths like~/.config/obsidian/init.luaas 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:
tablewidgetdefault wrong in Lua config reference — documented default was"cursor", actual default is"native". Valid values corrected to"native","raw". - Documentation:
oilconfirmdeletethresholddefault wrong — documented as5inlua-config.mdandvimrc.md, actual default is1(fromDEFAULT_SETTINGS). - Documentation: Oil vimrc/Lua option names wrong in settings reference —
oilexplorer→oil,oilshowhiddenfiles→oilhiddenfiles,oildefaultsort→oilsort. Oil confirm delete threshold range corrected from1–20to0–100. - Documentation: yank highlight duration range wrong — documented as
50–3000 msinsettings.md, actual range is0–5000 ms. - Documentation:
foldenablelisted as Settings UI toggle —foldenableis a CM Vim built-in option registered viavim.defineOption, not aVimMotionsSettingsproperty. 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>, andQ.- Test:
test/neovim/test-definitions.ts(68 new cases)
- Test:
- New golden spec file —
test/specs/vim-builtin/new-commands-golden.e2e.tsadds 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)
- Test:
Documentation
CHANGELOG.mdAGENTS.md: updated fork API and test infrastructure descriptionsCONTRIBUTING.md: updated file tree for new test fileKNOWN_LIMITATIONS.md: marked:moveand:copyas implemented, updated infra-limitation deviationsREADME.md: addedg@and new ex commands to features listdocs/reference/keybindings.md: added new insert mode and editing commandsdocs/features/ex-commands.md: added:moveand:copydetailsdocs/configuration/lua-config.md: addedvim.o.operatorfuncdocumentation, fixed shared config section,tablewidgetdefault,oilconfirmdeletethresholddefaultdocs/configuration/vimrc.md: fixed shared config section,oilconfirmdeletethresholddefaultdocs/configuration/settings.md: fixed oil vimrc/Lua names, yank highlight duration range, movedfoldenableto vimrc/Lua-only section, addedglobalConfigSearchsettingAGENTS.md: updated Lua configuration page ownership withglobalConfigSearch
[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.hasFocusalways evaluated totrue. 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 theactiveflag 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 (cachedgetComputedStyle,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 vialastLoopTime, dirty-rect tracking viamarkDirty()/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(mutabletargetQuadviaupdateQuadFromRect(), eliminates per-frame allocations) - Plugin:
src/vim/animated-cursor/config.ts(cachedprefers-reduced-motionmedia query with change listener) - Plugin:
src/vim/animated-cursor/types.ts(needsBlink()anddidDraw()added toTickableinterface)
- Plugin:
Tests
- Animated cursor idle rAF rate measurement — empirical e2e test that instruments
requestAnimationFrameto 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 patchedwindow.requestAnimationFrame, 5-second idle measurement, threshold assertion < 150)
- Test:
- Table-nav hotkey test uses platform-correct modifier on macOS — the
Ctrl+Pregression test for table-nav hotkey passthrough (#146) hardcodedKey.Control, which does not open the command palette on macOS (where the shortcut isCmd+P). The test now usesobsidianPage.getPlatform()and sendsKey.Commandon macOS,Key.Controlelsewhere.- Test:
test/specs/table-nav-hotkeys.e2e.ts(platform-aware modifier key viaobsidianPage.getPlatform())
- Test:
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: updated animated cursor section — marked rAF loop death as enhanced with gear system, added GPU optimization detailsdocs/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. AddedresolveOverlaps()— 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(addedresolveOverlaps()function, called after initial label creation inshowHints())
- Plugin:
- 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’sdefaultcase returnedtrue, telling the scope handler that every non-table-nav key was consumed. (3) The scope handler calledstopImmediatePropagation()on all consumed keys, preventing other DOM listeners from seeing them. Fixed by parenting both scopes toapp.scope, returningfalsefor unhandled keys, and removingstopImmediatePropagation. (#146)- Plugin:
src/vim/table-nav-controller.ts(parent bothScopeinstances tothis.app.scope, removestopImmediatePropagation) - Plugin:
src/vim/table-nav-keymap.ts(default: return true→return false)
- Plugin:
Vc(visual-line change) deletes extra line and mispositions cursor —Vto select a line thencdeleted 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’schangeoperator had no dedicated linewise visual branch — the catch-all visual path calledcm.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 dedicatedargs.linewisebranch that usescm.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— newargs.linewisebranch for visual-line change)
- Fork:
zz,zt,zbscroll to wrong positions with visible frontmatter properties — In Live Preview mode, when YAML frontmatter was rendered as a properties widget,ztscrolled to center instead of top,zzovershot center, andzbwas similarly mispositioned. Root cause: the codemirror-vim fork’scharCoords(pos, 'local')method usedcontentDOM.getBoundingClientRect()as the vertical coordinate reference, butscrollTo()operates onscrollDOM. In Obsidian, the.metadata-containerwidget sits insidescrollDOMbut outsidecontentDOM, creating a vertical offset between the two reference points. Fixed by splitting the coordinate reference:contentDOMfor horizontal (goalColumn tracking forgj/gk),scrollDOM+scrollTopfor vertical (scroll-content-space matching CM5localmode). The inverse methodcoordsCharwas updated to match. (#143)- Fork:
~/Repos/codemirror-vim/src/cm_adapter.ts(charCoordsandcoordsCharsplit coordinate reference:contentDOMfor horizontal,scrollDOMfor vertical)
- Fork:
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:Vcon middle line,Vcbefore empty line preserves it,Vcon last line,Vjcmulti-line change (#145) - 4 new Neovim golden test definitions in
test/neovim/test-definitions.ts+ golden data recorded intest/neovim/golden-data/visual-mode.json(#145) - 3 new e2e tests in
test/specs/vim-builtin/z-commands.e2e.ts:zz/zt/zbordering with frontmatter visible,ztvszzseparation with frontmatter,ztplaces cursor within top 15% of viewport with frontmatter (#143)
Documentation
CHANGELOG.mdKNOWN_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-spacecharCoords/coordsChar” section) - New
docs/guides/plugin-integration.md: community plugin integration guide with Better Paste recipes and genericvim.obsidian.run_commandpattern (#147) - New
docs/guides/lua-recipes.md: copy-paste Lua snippets for common workflows (display-linej/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 forflashSearch,easyMotionDimming,easyMotionLabels,labelFontSize,labelMatchFontSizein both declarative and imperative settings paths) - Styles:
styles.css(addedvim-motions-when-easymotion-or-flashandvim-motions-when-easymotion-or-hint-or-flashCSS visibility gate rules)
- Plugin:
Documentation
CHANGELOG.mddocs/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 Vimj/khavemotionArgs: { linewise: true }. Addedlinewise: trueto botheasyMotionLineandeasyMotionLineBackdefinitions.- Plugin:
src/easymotion/register.ts(addedmotionArgs: { linewise: true, forward: true/false }to line motion entries)
- Plugin:
- EasyMotion forward motions do not set
motionArgs.forward— the fork’sclipToLinefunction (which clips trailing newline+whitespace for multi-line forward operations) never fired for EasyMotion motions. Addedforward: trueorforward: falseto all 17 directional EasyMotion motion definitions.- Plugin:
src/easymotion/register.ts(addedforwardflag to allEASYMOTION_DEFSentries)
- Plugin:
- Insert-mode surround macro recording —
<C-g>s{char}keys typed during insert mode were not logged to the macro key buffer. The fork’shandleKeyInsertModenow callslogKeywhen a full insert-mode command is matched, recording the complete key sequence to the macro register.- Fork:
~/Repos/codemirror-vim/src/vim.js(handleKeyInsertMode—logKeyonmatch.type == 'full')
- Fork:
easyMotionRepeatoperator-pending mode —easyMotionRepeatwas registered as an action (defineAction) which cannot participate in operator-pending mode. Changed to a motion (defineMotion) withmapCommandbinding at<leader><leader>.. The motion inheritsmotionArgsfrom the last executed EasyMotion motion, so operators apply with correctlinewise/inclusive/forwardflags.- Plugin:
src/easymotion/register.ts(changed fromdefineActiontodefineMotion+mapCommand, addedlastMotionArgsstorage)
- Plugin:
:m/:tnewline 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. FixedgetLineRangeTextto include the preceding newline separator when extracting the last line, andcreateMoveCopyCommandto insert with correct newline placement at boundaries.- Plugin:
src/workspace/commands.ts(getLineRangeTextboundary handling,createMoveCopyCommandinsert text normalization)
- Plugin:
- Table cell count prefixes —
3jin 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 tonavigate(). (2)navigate()loopscounttimes for directional movement. (3) Cross-cell motion overrides (moveByLines,moveByDisplayLines) now looprepeattimes throughgetCellBelow/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,createMoveByDisplayLinesrepeat loop)
- Plugin:
- Cross-cell word motions —
w/b/e/W/B/E/ge/gEat cell boundaries did not jump to the adjacent cell. Added amoveByWordsoverride intable-cell-motions.tsthat detects when the word motion result isnullor stuck at a boundary, and crosses to the next/previous cell viagetNextCell.- Plugin:
src/vim/table-cell-motions.ts(newcreateMoveByWordsoverride, registered inapplyTableCellMotions)
- Plugin:
- 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(lastStructuralActiontracking,.handler with count) - Plugin:
src/vim/table-nav-controller.ts(clearLastStructuralActionon cell edit entry)
- Plugin:
- Multi-line
tcolumn 0 exclusion — forwardt{char}targets at column 0 were excluded entirely instead of wrapping to the previous line’s last character. Fixed in both flash (applyTillOffsetinchar-mode.ts) and EasyMotion (findTillTargetsintargets.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)
- Plugin:
- EXTRA_DEFS bidirectional motions lack motionArgs —
easyMotionBdEndWord,easyMotionBdEndWORD,easyMotionBdTillnow haveinclusive: true, andeasyMotionBdLinehaslinewise: true. Applied viaObject.assign(motionArgs, defArgs)inside the motion function body, bypassing thedefineMotion-only limitation.- Plugin:
src/easymotion/register.ts(EXTRA_DEFSmotionArgs + motion function mutation)
- Plugin:
- 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 (3Tabmoves 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)
- Plugin:
Tests
- 3 new e2e tests in
test/specs/easymotion-comprehensive.e2e.ts:d+easymotion jlinewise delete,y+easymotion jlinewise yank with register flag,d+easymotion klinewise delete upward - 2 new e2e tests in
test/specs/easymotion-comprehensive.e2e.ts:d+easyMotionRepeatoperator-pending delete,d+easyMotionRepeatafter line motion preserves linewise - 1 new e2e test in
test/specs/surround.e2e.ts: macro register contains<C-g>skeys after recording - 6 crash-guard tests in
test/specs/vim-builtin/ex-move-copy-normal.e2e.tsstrengthened 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:3jmoves 3 rows in table-nav,2lmoves 2 columns - 2 new e2e tests in
test/specs/table-cell-vim-mode.e2e.ts: cross-cellwat end of cell, cross-cellbat start of cell - 3 new e2e tests in
test/specs/table-nav-mode.e2e.ts:.repeatso,2.repeats twice,.cleared after cell edit - 3 new unit tests in
test/unit/flash-targets.test.ts:findTillTargetscolumn 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-linetwraps to previous line end at column 0 - 1 new e2e test in
test/specs/easymotion-comprehensive.e2e.ts:y + easyMotionBdLineviamapCommandyanks linewise (EXTRA_DEFS motionArgs)
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: marked EasyMotion linewise, forward motionArgs, insert-mode surround macro recording,easyMotionRepeatoperator-pending,:m/:taddress parsing, table count prefixes, cross-cell word motions, table-nav dot-repeat, multi-linetcolumn 0, EXTRA_DEFS motionArgs, and Tab/Shift+Tab in table-nav as fixedCONTRIBUTING.md: updatedeasymotion/register.tsandtable-cell-motions.tsdescriptions,table-nav-keymap.tsdescription- 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 tabledocs/features/easymotion.md: added “Repeat last motion” section with<leader><leader>.bindingdocs/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. gMmotion — go to middle character of the text line (by character count). Distinct fromgm(middle of screen line).- Fork:
~/Repos/codemirror-vim/src/vim.js(newmoveToMiddleOfTextLinemotion + defaultKeymap entry)
- Fork:
g&action — repeat last:ssubstitution on all lines in the buffer (equivalent to:%s//~/&).- Fork:
~/Repos/codemirror-vim/src/vim.js(newrepeatLastSubstituteGlobalaction + defaultKeymap entry)
- Fork:
]<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/addBlankLineAboveactions)
- Plugin:
<C-W>Taction — 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(moveToNewTabaction)
- Plugin:
<C-W>^action — split current window and edit the alternate file. Combines horizontal split + alternate file navigation.- Plugin:
src/workspace/navigation.ts(splitAlternateFileaction)
- Plugin:
<C-W>nmapping — alias for<C-W>s(new horizontal split). Matches Neovim’s:newequivalent.- Plugin:
src/workspace/navigation.ts(additionalmapCommandcall)
- Plugin:
g<Tab>mapping — go to last accessed tab page. Aliases the existingfocusPreviousPaneaction.- Plugin:
src/workspace/navigation.ts(additionalmapCommandcall)
- Plugin:
]f/[fmappings — alias forgf(go to file). Matches Neovim bracket file navigation.- Plugin:
src/workspace/navigation.ts(additionalmapCommandcalls)
- Plugin:
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(newselectedTextquerySrc + visual-context keymap entries)
- Fork:
Kkeyword lookup — adapted for Obsidian: triggers hover page preview on wikilinks, opens external URLs in browser, falls back togacharacter info on plain text.- Plugin:
src/workspace/navigation.ts(createKeywordLookupAction)
- Plugin:
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: select0\n0\n0, pressg<C-A>→1\n2\n3.- Fork:
~/Repos/codemirror-vim/src/vim.js(sequential path inincrementNumberToken) - Fork:
~/Repos/codemirror-vim/src/types.ts(sequentialadded toActionArgsPartial)
- Fork:
:center/:left/:rightex commands — text alignment.:cecenters lines,:leleft-aligns (trims),:riright-aligns. Optional width argument (default:textwidthor 80).- Plugin:
src/workspace/commands.ts(createAlignCommand)
- Plugin:
:retabex command — replaces all tab characters with spaces using the currenttabSize(or an explicit argument).- Plugin:
src/workspace/commands.ts
- Plugin:
]m/[m/]M/[Mmethod navigation — verified working via the fork’s genericmoveToSymbolmotion withmethodmode. 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(newmoveToInsertStartmotion,_insertStartPostracking inenterInsertMode/exitInsertMode) - Fork:
~/Repos/codemirror-vim/src/types.ts(_insertStartPosadded tovimState)
- Fork:
:d3/:m0style ex commands didn’t work — the ex command parser treated trailing digits as part of the command name (e.g.,d3→ unknown commandd3). FixedmatchCommand_to have a fallback pass matching commands by progressively shorter alpha prefixes, and_processCommandto extract the trailing suffix into arguments. Also fixes:g/pattern/m0subcommand routing.- Fork:
~/Repos/codemirror-vim/src/vim.js(matchCommand_fallback pass,_processCommandarg extraction)
- Fork:
:$dcursor position differed from Neovim — after deleting lines with:d, cursor is now positioned at the first non-blank ofmin(startLine, lastLine()), matching Neovim.- Fork:
~/Repos/codemirror-vim/src/vim.js(exCommands.deletecursor positioning)
- Fork:
:move/:mand:copy/:co/:tdeviations were stale — the plugin already implements these viacreateMoveCopyCommandinsrc/workspace/commands.ts, overriding the fork’s cursor-only stub. Deviation entries indeviations.tsshould 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.mdNEOVIM_MAPPING_DIFFERENCES.md(new file — full audit document, updated after each round)- Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(addedgM,g&,:d count,<C-U>insert-start,v_*/v_#, ex command name parsing,:$dcursor,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’sg_.dg_deletes through the last non-blank character, preserving trailing whitespace.- Fork:
~/Repos/codemirror-vim/src/vim.js(newmoveToLastNonWhiteSpaceCharactermotion handler andg_keybinding) - Fork:
~/Repos/codemirror-vim/src/types.ts(type definition)
- Fork:
Fixed
g^andg0display-line motions behaved identically — both calledcursorLineBoundaryBackward, which implements Home-key toggle behavior (first non-blank ↔ column 0).g0incorrectly went to first non-blank instead of column 0, andg^toggled instead of always going to first non-blank. Nowg0unconditionally moves to column 0 of the visual line, andg^unconditionally moves to the first non-blank character of the visual line, matching Neovim. (#141)- Fork:
~/Repos/codemirror-vim/src/vim.js(newmoveToFirstNonBlankOfDisplayLinehandler forg^;moveToStartOfDisplayLinenow usesgoDisplayLineStart) - Fork:
~/Repos/codemirror-vim/src/cm_adapter.ts(newgoDisplayLineStartexec command usingview.moveToLineBoundary) - Fork:
~/Repos/codemirror-vim/src/types.ts(type definition formoveToFirstNonBlankOfDisplayLine)
- Fork:
Tests
- 7 new e2e tests in
test/specs/vim-builtin/g-commands.e2e.ts:g0always 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(g0indented,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.mdAGENTS.md: addedg0/g^/g_fork motion descriptions- Fork:
~/Repos/codemirror-vim/DIFFERENCES.md: addedg0/g^fix under “Behavioral fixes (Neovim parity)” andg_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’sactive-leaf-changehandler attached event listeners to the new adapter but never read the new adapter’s current vim mode — the display only updated when a subsequentvim-mode-changeevent fired. If the destination editor was already in Normal mode, no event fired and the indicator stayed stale. Fixed by addingsyncModeFromAdapter()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(newsyncModeFromAdaptermethod; called afterattachToAdapterinactive-leaf-changehandler)
- Plugin:
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)
- CI:
- 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-servicehandles Obsidian download, ChromeDriver version matching, and platform-specific launch per OS.CSC_IDENTITY_AUTO_DISCOVERY=falseprevents macOS keychain prompts. 40-minute timeout per job accommodates slower runners. Windows shards retry up to 3 times onEPERMerrors fromobsidian-launcher’s atomic file rename (Windows NTFS file locking duringonPreparedownload).- CI:
.github/workflows/e2e.yml(newe2e-cross-platformjob withos: [macos-latest, windows-latest]matrix, EPERM retry logic)
- CI:
Fixed
- E2e tests using
Key.CtrlsendCmdon macOS instead ofCtrl— WebDriverIO’sKey.Ctrlis a cross-platform abstraction that maps toCmdon macOS andCtrlon Linux/Windows. Vim keybindings like<C-w>and<C-t>require the physical Control key. Changed all vim-related test key dispatches fromKey.CtrltoKey.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 OilC-ttest failure — all macOS-only.- Test:
test/specs/global-nav.e2e.ts(Key.Ctrl→Key.Controlfor<C-w>h/l/q/s/d) - Test:
test/specs/global-nav-plugin-leaf.e2e.ts(Key.Ctrl→Key.Controlfor<C-w>h) - Test:
test/specs/gmap.e2e.ts(Key.Ctrl→Key.Controlfor<C-w>hand which-key tests) - Test:
test/specs/gmap-vimrc.e2e.ts(Key.Ctrl→Key.Controlfor<C-w>bindings) - Test:
test/specs/oil-poc.e2e.ts(Key.Ctrl→Key.ControlforC-topen-in-new-tab)
- Test:
- 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 strictscrollBefore > 0assertion 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 beforewaitForTableWidget, removed viewport size assumption)
- Test:
- Upgraded
@obsidian-typings/obsidian-public-latestfrom^6.32.0to^6.33.0— pulls in@obsidian-typings/obsidian-public-1.13.7@1.6.0which includes fullTableEditor,TableCell,TableRow,TableSelectionBounds,CellDirection,CellPosition,CursorPlacement, andTableAlignmenttype definitions. Replaced local runtime-discovered typings (ObsidianTableEditor,ObsidianTableCell,ObsidianTableRow) with the upstream types. Deletedsrc/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-latestinstead of local file) - Plugin:
src/vim/table-nav-controller.ts(imports from@obsidian-typings/obsidian-public-latestinstead of local file) - Plugin:
src/vim/table-cell-motions.ts(imports from@obsidian-typings/obsidian-public-latestinstead of local file)
- Plugin:
Documentation
CHANGELOG.mdAGENTS.md: addedTableEditor/TableCellto key typed APIs list; updated CI container image description to reflect sharding strategy; added cross-platform e2e CI description with EPERM retryCONTRIBUTING.md: removed deletedtable-editor.d.tsfromsrc/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, orEnter), 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()calledclearCursorSuppressedForView()andresumeAnimatedCursorForView()immediately, but cell editor focus was deferred by 150ms viasetTimeoutinfinishCellEditEntry(). During the gap, the parent editor still had focus and itsBlockCursorPluginre-rendered the unsuppressed cursor at the table-range position wherecoordsAtPos()returns the full widget height. Additionally, even after focus transfer, a pendingrequestMeasurecallback from a prior update cycle could redraw the cursor withsuppressed=false. Fixed by removing both calls entirely — parent cursor suppression stays active throughout the nav→edit transition.exitCellEditToNav()andexitTable()handle restoring suppression state on exit. (#136)- Plugin:
src/vim/table-nav-controller.ts(removedclearCursorSuppressedForView+resumeAnimatedCursorForViewfromenterCellEdit)
- Plugin:
- Visual/Visual Line put command not replacing selection —
P,gp, andgPin visual or visual-line mode inserted text at the cursor instead of replacing the selected text. Two root causes: (1) the plugin’spasteFromRegister()(used byP/gp/gP) had no visual mode handling — it always performed a cursor-relative insert; (2) the fork’scontinuePaste()(used byp) read the CM6 selection viagetSelectedAreaRange()andcm.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(newpasteInVisualMode()function; visual mode detection inpasteFromRegister()delegates to it; readsvim.selfor correct range, handles visual-char and visual-line, stores replaced text in unnamed register, callsVim.exitVisualMode()) - Plugin:
src/types/vim-api.d.ts(addedexitVisualModetoVimApiinterface) - Fork:
~/Repos/codemirror-vim/src/vim.js(continuePastevisual-line branch now derivesselectionStart/selectionEndfromvim.selinstead of collapsed CM6 selection;selectedTextusescm.getRange()instead ofcm.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)
- Plugin:
Tests
- 1 new regression test in
test/specs/table-cursor-suppression.e2e.ts(#136): parent cursor hidden after entering cell edit viai - 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.mdKNOWN_LIMITATIONS.md: marked parent cursor visible during cell editing as fixed (#136); updated “Table navigation cursor hiding” section with deferred unsuppression inenterCellEditAGENTS.md: addedexitVisualModeto fork API descriptionKNOWN_LIMITATIONS.md: updated yank-ring section (visual-modegp/gPno longer bypass yank-ring); updated V-LINE cursor-only section (fork’scontinuePastenow 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
scrollTopvia 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(preEntryScrollTopsession field, scroll-lock listener inscheduleEntry,focusWithoutScroll, scroll restore intryEnter+ rAF safety net)
- Plugin:
- Table-nav ignores scrolloff setting — table-nav cell scrolling used hardcoded 5px margins instead of the user’s
scrolloffsetting. Withscrolloff=999(typewriter/centered cursor), the highlighted cell stayed at the viewport edge instead of staying centered. Replaced dispatch-basedsyncCursorToActiveCellwith DOM-levelscrollHighlightedCellIntoView(avoids CM6 update cycle side effects) and updatedtableNavScrollHandler— both now read the scrolloff margin viagetScrolloffMargin()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, replacedsyncCursorToActiveCell,tableNavScrollHandlerusesgetScrolloffMargin,refreshAfterDocChangescrolls after structural ops) - Plugin:
src/vim/scrolloff.ts(newgetScrolloffMargin()export)
- Plugin:
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.mdto 100 rows with 30 lines of leading content
Documentation
CHANGELOG.mdKNOWN_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
Enterin table-nav mode (normal-mode cell entry), pressingEscapedid 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 hadmode: null(pre-initialization) which causedisVimIdle()to returnfalse. Fixed by installing a capture-phasekeydownlistener ondocumentduring cell-edit mode that intercepts Escape when vim is idle, and introducingisCellVimIdle()which treatsnull/undefinedmode as idle (equivalent to normal mode during cell editor initialization). (#136)- Plugin:
src/vim/table-nav-controller.ts(isCellVimIdle,installCellEscapeCapture,removeCellEscapeCapture,cellEscapeCleanupsession field)
- Plugin:
- Community plugin
replaceSelectionfails in visual-line mode — community plugins like Note Refactor that calleditor.replaceSelection()after async operations (file creation, link generation) failed to replace the selected text in visual-line mode. The existingwithExpandedSelectionwrapper expanded the CM6 selection synchronously and restored cursor-only in itsfinallyblock, but async command callbacks executedreplaceSelection()after the selection was already restored. Additionally,editor.replaceSelection()was not patched in theVisualLineSomethingSelectedPatchViewPlugin (onlysomethingSelected()andgetSelection()were). Fixed by patchingeditor.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 viaVim.handleKey(cm, '<Esc>')after replacement since the selection has been consumed. (#138)- Plugin:
src/vim/visual-line-command-fix.ts(replaceSelectionpatch inVisualLineSomethingSelectedPatch, cleanup indestroy())
- Plugin:
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()intest/specs/table-cell-vim-mode.e2e.ts - 5 new spike tests in
test/specs/spikes/spike-issue138-vline-async-replaceSelection.e2e.ts(#138): V-LINEgetSelection()baseline, syncreplaceSelectionviaexecuteCommandById, asyncreplaceSelectionvia fake command (Note Refactor pattern), direct asyncreplaceSelectionwithoutexecuteCommand, sync directreplaceSelectionwithout wrapper
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: marked Escape-from-cell-edit bug as fixed; updated V-LINE passthrough section withreplaceSelectionpatch details and updated trade-off/test coverage
[0.120.1] - 2026-08-21
Changed
- Removed
!importantfrom table-nav CSS — replacedoverflow: visible !importanton.vim-motions-table-nav-mode .cm-table-widgetwith higher-specificity selectors (.cm-editor .vim-motions-table-nav-mode.cm-table-widget). The added.cm-editorancestor provides enough specificity to override Obsidian’s built-inoverflow: hiddenwithout!important.- Styles:
styles.css(increased selector specificity, removed!important)
- Styles:
Documentation
CHANGELOG.mdAGENTS.md: added “Never use!importantin CSS” coding conventionCONTRIBUTING.md: added!importantavoidance 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 — newrepeatLastExCommandaction replays the most recent ex command. Added to defaultKeymap.- Fork:
~/Repos/codemirror-vim/src/vim.js(repeatLastExCommandaction + defaultKeymap entry)
- Fork:
- Fork:
&repeat last:son current line — newrepeatLastSubstituteaction re-executes the last:ssubstitution. Added to defaultKeymap.- Fork:
~/Repos/codemirror-vim/src/vim.js(repeatLastSubstituteaction + defaultKeymap entry)
- Fork:
- Fork:
ZZwrite+quit andZQquit without saving — mapped in defaultKeymap viaexArgs: { input: 'wq' }andexArgs: { input: 'q' }respectively.- Fork:
~/Repos/codemirror-vim/src/vim.js(defaultKeymap entries)
- Fork:
- Fork: Insert
<C-a>re-insert previously inserted text — newreinsertPreviousInsertaction replays the last insert-mode text at the cursor.- Fork:
~/Repos/codemirror-vim/src/vim.js(reinsertPreviousInsertaction)
- Fork:
- Fork: Insert
<C-e>copy character from line below — newcopySameColumnBelowaction copies the character at the same column from the line below.- Fork:
~/Repos/codemirror-vim/src/vim.js(copySameColumnBelowaction)
- Fork:
- Fork: Insert
<C-y>copy character from line above — newcopySameColumnAboveaction copies the character at the same column from the line above.- Fork:
~/Repos/codemirror-vim/src/vim.js(copySameColumnAboveaction)
- Fork:
<C-w>w/<C-w>Wcycle panes — cycle focus to the next or previous pane in the workspace.- Plugin:
src/workspace/navigation.ts
- Plugin:
<C-w>pfocus previous pane — jump to the previously accessed pane using leaf ID tracking.- Plugin:
src/workspace/navigation.ts - Plugin:
src/main.ts(previousLeafIdtracking extracted from Harpoon-gated handler into unconditional handler)
- Plugin:
gmgo to middle of screen line — positions cursor at the horizontal midpoint of the visible editor area.- Plugin:
src/workspace/navigation.ts
- Plugin:
gogo to character offset — jumps to the Nth byte offset in the buffer (with count prefix).- Plugin:
src/workspace/navigation.ts
- Plugin:
g8show UTF-8 byte sequence — displays the UTF-8 hex byte values for the character under the cursor.- Plugin:
src/workspace/navigation.ts
- Plugin:
gFgo 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
- Plugin:
<C-g>show file info — displays filename, line count, cursor position, and percentage through file in a notice.- Plugin:
src/workspace/navigation.ts
- Plugin:
<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/lastMarkdownFilePathfields +<C-^>/<C-6>mapping)
- Plugin:
<C-]>follow link under cursor — alias forgd(go to definition).- Plugin:
src/main.ts
- Plugin:
<C-t>pop from link follow — alias for jump list backward navigation.- Plugin:
src/main.ts
- Plugin:
zs/ze/zH/zLhorizontal scroll commands — scroll the viewport horizontally without moving the cursor.- Plugin:
src/workspace/navigation.ts
- Plugin:
- Ex commands for
:move,:copy, and:normal— add:m/:moveline moves,:t/:copy/:coline copies, and:normal/:normal!key dispatch from the ex line.- Plugin:
src/workspace/commands.ts(line transfer helpers +:normalkey feeding)
- Plugin:
:tabmoveno-op registration — registered as a no-op with a notice (Obsidian has no tab reorder API).- Plugin:
src/workspace/commands.ts
- Plugin:
- 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), normalU,<C-l>,g<C-a>,g<C-x>, insert<C-r>=,<C-k>,<C-v>,<C-x>family.- Plugin:
src/workspace/navigation.ts
- Plugin:
Fixed
- Table cell cursor bounce-back on macOS (continued) — the
MessageChannel-basedscheduleCrossing()from 0.119.0 still raced with Obsidian’s table widget focus handlers on macOS Electron. Replaced withrequestAnimationFrame, 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(scheduleCrossingnow usesrequestAnimationFrameinstead ofMessageChannel)
- Plugin:
- 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 becausenavigate()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 anEditorView.scrollHandlerfacet that intercepts scroll requests during table-nav mode, reads the highlighted cell’s DOM bounding rect, and adjustsscrollDOM.scrollTopdirectly — the CM6-sanctioned mechanism for custom scroll behavior that is not overridden by viewport reconciliation. Addedoverflow: visibleCSS override on the table widget during nav mode to prevent the widget’soverflow: auto hiddenfrom blockingscrollIntoViewpropagation. (#136)- Plugin:
src/vim/table-nav-controller.ts(syncCursorToActiveCell,tableNavScrollHandlerextension) - Styles:
styles.css(overflow: visibleon.vim-motions-table-nav-mode.cm-table-widget)
- Plugin:
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.mdAGENTS.md: updated fork description with new actions, updated test file organizationKNOWN_LIMITATIONS.md: added:m/:taddress parsing limitationdocs/reference/keybindings.md: added new normal-mode, insert-mode, window, g-prefix, z-prefix, and ex command entriesdocs/features/ex-commands.md: added:m/:t/:normalediting commands sectiondocs/features/workspace-navigation.md: added pane cycling, alternate file, link navigation, and document info sectionsCONTRIBUTING.md: updatedtable-cell-motions.tsandtable-nav-controller.tsdescriptionsKNOWN_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 usedsetTimeout(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 withj→gj/k→gkremappings were especially affected because the remapping adds an extra key processing step. Fixed by replacingsetTimeout(0)withMessageChannelport 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(scheduleCrossingnow usesMessageChannelinstead ofsetTimeout(0))
- Plugin:
- Cross-cell motions broken when
enableTableNav=false— the 0.118.0 fix for #136 incorrectly gatedapplyTableCellMotions()onenableTableNav, which removed the motion overrides entirely when table nav was disabled. This brokej/k/h/lcross-cell navigation in native table cell editors. Reverted the gate — cross-cell motions are independent ofenableTableNav, as originally designed. (#136)- Plugin:
src/main.ts(removedenableTableNavcheck from bothapplyTableCellMotionsregistration sites)
- Plugin:
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 andwaitUntilfor async assertions - Added raw-mode tests with
j→gj/k→gkremappings (j down, k up) - Updated
test/specs/table-cell-vim-mode.e2e.ts: cross-cell j/l tests usewaitUntilfor deterministic assertions - Updated
test/specs/table-cursor-suppression.e2e.ts: #127 tests useG/ggjumps instead of j/k table traversal; #135 tests use WebDriver cell click +waitUntilfor table-nav entry
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: reverted cross-cell motions documentation to reflect independence fromenableTableNavCONTRIBUTING.md: updatedtable-cell-motions.tsdescriptiondocs/features/tables.md: reverted table modes matrix and cell editor behavior descriptiondocs/configuration/settings.md: reverted Table navigation setting description
[0.118.0] - 2026-08-20
Changed
- Cross-cell motions now respect
enableTableNav—h/j/k/lcrossing cell boundaries in native table mode was previously always active regardless ofenableTableNav. Cross-cell motions now only activate whenenableTableNavistrue. When disabled, Obsidian’s native table cell editor handles cell boundary navigation directly.
Fixed
- Table movement broken when
enableTableNav=false(macOS) —applyTableCellMotions()overrodemoveByLines,moveByCharacters, andmoveByDisplayLinesglobally whenevertableWidgetModewasnative, regardless ofenableTableNav. When table nav was disabled, these overrides still interceptedj/k(andgj/gk) inside native table cells, callingscheduleCrossing()withsetTimeout(0)which raced with Obsidian’s native cell focus management on macOS — producing cursor bounce-back. Users withj→gj/k→gkremappings (common vimrc/Lua pattern) were especially affected because the remapping routes throughmoveByDisplayLines. Fixed by gatingapplyTableCellMotions()onenableTableNav— 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(addedenableTableNavcheck to bothapplyTableCellMotionsregistration sites)
- Plugin:
Tests
- Updated
test/specs/table-nav-disabled.e2e.ts(#136): replaced flaky native table cross-cell tests withenableTableNav=falseno-overlay assertion; added 2 raw mode tests withj→gj/k→gkremappings (j down, k up) - Updated
test/specs/table-cell-vim-mode.e2e.ts:enableTableNav=falsecross-cell tests now assert cursor stays within cell (no cross-cell override)
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: updated cross-cell motions documentation to reflectenableTableNavgating; updated table modes matrix; updatedtablewidgetmode descriptionCONTRIBUTING.md: updatedtable-cell-motions.tsdescription to reflectenableTableNavgatingdocs/features/tables.md: updated table modes matrix and cell editor behavior description for disabled table-navdocs/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 returnfalse, so commands that check for a selection silently failed. The existingexecuteCommandwrapper expanded the selection before command execution, but Obsidian’s command palette invokescheckCallback()directly on the command object, bypassingexecuteCommandentirely. Fixed by wrapping every command’scheckCallbackto expand the visual-line selection before the callback runs, and wrappingaddCommandto cover commands registered after plugin load. (#137)- Plugin:
src/vim/visual-line-command-fix.ts(wrapcheckCallbackon all commands +addCommandhook; extract sharedwithExpandedSelectionhelper)
- Plugin:
- 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 theenterTableNavstate effect, soisTableNavActive()always returnedfalse— themainEditorTableCursorGuardcontinued 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 calledclearCursorSuppressedForView()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 freshBlockCursorPluginwith a visible cursor layer — the controller now suppresses these on every ViewUpdate viasuppressWidgetCursorLayers(). (#135)- Plugin:
src/vim/table-nav-controller.ts(dispatchenterTableNaveffect,clearVimCursorLayer()helper,suppressWidgetCursorLayers()on every update + entry + rAF safety net) - Plugin:
src/vim/table-cell-cursor-guard.ts(cellEditorCursorGuard.destroy()guards onisTableNavActive())
- Plugin:
Tests
- 3 regression tests in
test/specs/visual-line-command.e2e.ts(#137):note-composer:split-filecheckCallbackreturnstruein V-LINE,editor.somethingSelected()returnstruein V-LINE,executeCommandByIdaffects 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 withenableTableNav=false— j down, k up, j exits table
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: updated visual-line command passthrough description to reflectcheckCallbackwrapping for command palette path (#137); updated table navigation cursor hiding entry; added #136 cross-reference to #132 cursor disappearing fixCONTRIBUTING.md: updatedtable-nav-controller.tsandtable-cell-cursor-guard.tsdescriptions
[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 (pollsxdpyinfoandherbstclientevery 200ms, fails after 6s) instead of the previoussleep 1race condition. Eliminates per-runnerapt-get update+apt-get install(~30s per shard × 79 shards). The image is built and pushed to GHCR by.github/workflows/docker-e2e-runner.ymlon 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(usescontainer:with the custom image, removedSetup virtual displaystep andactions/setup-node, added npm cache viaactions/cache)
- New:
Documentation
CHANGELOG.mdAGENTS.md: Added CI container image documentation to Automated testing sectionCONTRIBUTING.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) —zjmoves to the start of the next foldable region (skipping child folds within the current heading’s range, matching Neovim’s sibling-fold semantics).zkmoves to the end of the previous foldable region.[z/]znavigate 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)
- Plugin:
- Fold state commands (
zn,zN,zi,zv,zF,zx,zX) —zndisables folding (opens all folds, prevents new folds).zNre-enables folding.zitoggles.zvopens folds to reveal cursor line.zFcreates a fold for [count] lines.zx/zXreapply fold level (preserving manual folds). Configure viaset foldenable/set nofoldenablein vimrc orvim.opt.foldenablein Lua.- Plugin:
src/fold/fold-enable.ts(NEW —foldEnableFieldStateField,isFoldingEnabledguard,zn/zN/ziactions) - Plugin:
src/fold/commands.ts(zv,zFactions) - Plugin:
src/fold/fold-level.ts(zx,zXactions) - Plugin:
src/vim/options.ts(foldenablevim option)
- Plugin:
- Heading fold provider with trailing blank line trimming — custom
foldServiceprovider for Markdown headings that trims trailing blank lines from fold ranges, matching Neovim’s treesitter fold boundaries. Overrides Obsidian’s built-infoldNodeProp-based heading folds.- Plugin:
src/fold/provider.ts(headingFoldfunction added tomarkdownFoldProvider)
- Plugin:
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)
- Plugin:
- Fold-enable guards — fold-creating/closing actions (
zc,za,zM,zm,zf,zF) respect thefoldenablestate. Unfold operations (zo,zR,zr,zv) always work regardless offoldenable.- Plugin:
src/fold/commands.ts,src/workspace/navigation.ts,src/fold/fold-level.ts
- Plugin:
Tests
- 12 Neovim golden test definitions for fold motions in
test/neovim/test-definitions.ts(fold-motionssuite 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-pendingdzj/dzk, no-op without foldable regions) - 3 known deviations registered in
test/neovim/deviations.ts:zkcount (treesitter fold hierarchy),[z/]z(fold body vs heading boundary)
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: UpdatedzO/zC/zAandzn/zNrows as fixed; updated golden test coverage table with new fold commandsCONTRIBUTING.md: Updatedsrc/fold/structure with new filesREADME.md: Updated Folding feature descriptiondocs/reference/keybindings.md: Added Fold commands sectiondocs/features/workspace-navigation.md: Added Fold motions, Fold state, Recursive fold sectionsdocs/configuration/settings.md: Addedfoldenableoption
[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 theonChangehandler fired the option’snotifycallback, which calledonSettingOverrideand added the key tovimrcOverrides— makingisOverridden()returntrueandrefreshDomState()disable the field. TheclearSettingOverride()call ran beforevim.setOption(), so the override was re-added immediately after clearing. Fixed by movingclearSettingOverride()to aftervim.setOption()in bothsetControlValue(declarative/post-1.13 path) and all 9 imperativeonChangehandlers forVIM_OPTION_KEYSsettings. (2) The initial settings sync inreloadFeatures()calledvim.setOption()for non-default values (clipboard, textwidth, insertmodeescape, etc.) afterregisterVimOptions()had already setregistered = true, causingnotifyto fire and mark these settings as overridden. Fixed by makingregisterVimOptions()return an activation function —registeredis only set totruewhen the caller invokes it after the initial sync completes. (#125)- Plugin:
src/settings.ts(setControlValue—clearSettingOverridemoved aftervim.setOption+refreshDomState; 9 imperativeonChangehandlers — same reorder) - Plugin:
src/vim/options.ts(registerVimOptions— returns() => voidactivation function instead of settingregistered = trueinternally) - Plugin:
src/main.ts(captures activation function, calls it after initial settings sync block)
- Plugin:
[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:mainEditorTableCursorGuardsuppressed 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-widgetelements; in raw mode they exist but are hidden viadisplay: none. In both cases, the cursor was suppressed with no alternative cursor shown. Fixed by adding ahasVisibleTableWidget()check that requires at least one.cm-table-widgetelement with a non-nulloffsetParentbefore suppressing. The check also short-circuits thefindTableRanges()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)
- Plugin:
- Cell-edit
h/j/k/lunconditionally exits to table-nav in normal mode — when editing a table cell with table-nav enabled, pressingh/j/k/lin 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: thecellEditScopehjkl handlers only checkedisVimIdle()— if idle, they unconditionally calledexitCellEditToNav()+navigate()without checking whether the cursor was at a cell boundary. Fixed by adding acursorAtCellBoundary()method that checks cursor position against cell content bounds:hexits only atch <= 0,latch >= lineLen - 1,jat last line,kat first line. When the cursor is not at the boundary, the handler returnsundefinedto let vim process the key as normal in-cell movement. (#131)- Plugin:
src/vim/table-nav-controller.ts(cursorAtCellBoundarymethod;installCellEditScopehjkl handlers — boundary check beforeexitCellEditToNav)
- Plugin:
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):lmid-cell stays in cell,hmid-cell stays in cell,lat end exits to nav,hat start exits to nav, insert→Escape→lstays 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 visiblethis.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
afterTesthook inwdio.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,ensureSourceModenow throw with context (e.g.,"setupEditor: no MarkdownView (active leaf type: graph)") instead of silently returning defaults. waitUntil-based synchronization:setupEditorwaits for content match,loadSingleFileWorkspacewaits for MarkdownView,ensureLivePreview/ensureSourceModewait for mode change — replacing fixedbrowser.pause()delays.- Settings mutation reliability:
setPluginSettingnow awaitssaveSettings(). NewsetPluginSettingAndReloadhelper sets + saves + callsreloadFeatures()+ waits for settle. - Golden enforcement:
testWithNeovimnow throws"Missing golden case"when no golden data exists (unless the test is a known deviation), preventing silent passes. - Hint-mode link navigation:
findHintLabelForLinkupdated to usegetBoundingClientRect()with CSS var fallback, wider CM6 selectors (.cm-link,.cm-url,[data-href]), and active-leaf scoping (.workspace-leaf.mod-active .cm-editor).
- Global
- 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)findHintLabelForLinkscoped 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,.mdauto-append, Windows line endings, names with spacesoil-diff.test.ts(11 tests): rename/delete/create detection, foreign ids, move resolution across multi-buffer diffsvimrc-parser.test.ts(35 tests): all 13 command types, noremap detection, context inference, icon/color extraction, comments, multi-line parsingflash-labeler.test.ts(10 tests): label assignment, distance sorting, 2-char labels, reuse, skipCharsfold-persistence.test.ts(7 tests): load/save round-trip, removePath, renamePath, TTL eviction, max entries evictionpair-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/jjescape sequences, character cleanup, timeout behavior, non-matching sequences, empty configscrolloff-cursorline-smoke.e2e.ts(4 tests): scrolloff setting persistence + cursor positioning, cursorline enable/disable cyclecontext-actions-smoke.e2e.ts(1 test)::contextactionscommand opens a modal
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Added cursor disappears in source/raw mode as fixed (#132); updated cross-cell navigation description —h/j/k/lnow move within cell when cursor is not at boundary (#131)CONTRIBUTING.md: Updatedtable-cell-cursor-guard.tsdescription withhasVisibleTableWidget()check; updatedtable-nav-controller.tsdescription withcursorAtCellBoundaryboundary checkdocs/features/tables.md: Added note about cursor visibility fix in source/raw mode; updated cell-edit behavior description —h/j/k/lmove within cell before boundary exitAGENTS.md: Updated test helpers description (strict behavior,waitUntilsynchronization,setPluginSettingAndReload); addedafterTesthook and vault fixtures documentation; added golden enforcement description; updated unit test listCONTRIBUTING.md: Updated test infrastructure tree (vault fixtures, snippets subdirs,test-wrapper.tsgolden 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 ascasts across 16 source files by leveraging@obsidian-typings/obsidian-public-latestv6.32.0 typed APIs. Totalas unknown ascount 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()andapp.commands.commandsaccessed directly via typedCommandsinterface; customObsidianCommandnarrowed toPick<Command, 'id' | 'name'>src/util/leaf.ts:leaf.idandleaf.pinnedused directly (required properties viaWorkspaceItem/WorkspaceLeafaugmentation);getViewFilePath()/getViewFileBasename()useinstanceof FileViewguard instead ofas unknown as { file? }castsrc/util/vault.ts:ConfigItemimported from@obsidian-typings/obsidian-public-latestreplacing customVaultConfigKeytype inferencesrc/workspace/global-defaults.ts:mdView.getMode()called directly (typed asMarkdownViewModeType)src/editors/embeddable-editor.ts:app.embedRegistryaccessed directly;editorApp.scopeaccessed directly (official API);workspace.activeEditorassignment typed viaMarkdownFileInfosrc/oil/keybindings.ts,src/oil/manager.ts:app.internalPlugins.getEnabledPluginById('file-explorer')returns typedFileExplorerPluginInstancewithrevealInFolder(item: TAbstractFile)src/oil/oil-view.ts:this.leaf.updateHeader()called directly (typed onWorkspaceLeafaugmentation)src/oil/manager.ts:app.openWithDefaultApp(path)called directly (typed onAppaugmentation)src/vim/native-table-adapter.ts:EditModetype extendsMarkdownEditViewinstead ofRecord<string, unknown>;view.editModeaccessed directly;isInLivePreview()usesview.getMode()+editMode.sourceModeinstead ofgetState()cast;getEditModeForView()usesinstanceof MarkdownViewguardsrc/vim/table-cell-cursor-guard.ts:mdView.editor.cmaccessed directly (typed asEditorViewviaEditoraugmentation)src/ui/global-ex-command.ts:this.inputElaccessed directly (officialSuggestModal.inputEl)src/lua/loader.ts:view.getViewType()called directly (officialViewAPI) — 3 instancessrc/settings.ts:this.display()andthis.refreshDomState()— reverted, casts retained to bypassobsidianmd/no-unsupported-apiand@typescript-eslint/no-deprecatedlint rules (pluginminAppVersionis 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 typedPluginsinterface
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 callsgetSettingDefinitions()and caches the result for rendering and search indexing. Inonload(),addSettingTab()ran beforecreateBundledVimExtension(), so thedisabledcallbacks closed overforkActive = false(aconstcaptured at the top ofgetSettingDefinitions()). The callbacks always returnedtrue(disabled) regardless of the actual fork activation state. Fixed by replacing the capturedforkActiveconst in all 5disabledcallbacks with a directisBundledVimActive()call, so Obsidian’srefreshDomState()always evaluates the current state. Additionally,this.declarativeSettingTab.update()is now called aftercreateBundledVimExtension()to refresh the cachedgetSettingDefinitions()result — this updates the static description text which cannot use a callback. (#128)- Plugin:
src/settings.ts(5 cursor shapedisabledcallbacks —!forkActive→!isBundledVimActive()) - Plugin:
src/main.ts(store setting tab reference asdeclarativeSettingTab; calldeclarativeSettingTab.update()aftercreateBundledVimExtension())
- Plugin:
- Animated cursor suppression not synced on
reloadFeatures()—setCursorSuppressed(this.settings.animatedCursor)was only called during initial plugin load (onload()), not duringreloadFeatures(). Any runtime setting change that calledreloadFeatures()(settings UI toggle, vimrcset smoothcursor, Luavim.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-brokentable-cursor-suppression.e2e.tstest (5 of 6 failures since commit99e5fea) whoseenableAnimatedCursor()helper set the setting and calledreloadFeatures()but never triggered the global suppression. (#127)- Plugin:
src/main.ts(reloadFeatures()— addedsetCursorSuppressed(this.settings.animatedCursor)call)
- Plugin:
- 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 CSSbaseThemerule to hide native cursor layers, but mode transitions (insert removes.cm-vimMode, normal re-adds it) and CM6’sdrawSelectionextension left native layers visible due to CSS specificity conflicts. Fixed in the fork by unconditionally hiding native CM6 cursor layers and settingcaretColorto 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-awarecaretColor) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(updatedsetCursorSuppressedAPI section)
- Fork:
- Doubled cursors in embedded editors (textarea vim overlay) — in the textarea vim overlay,
caretColorwas 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-vimModeDOM 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 checkingthis.cm.state.vim.insertModedirectly instead of the DOM class. Also usessetProperty("caret-color", ..., "important")for CSS specificity robustness. (#130)- Fork:
~/Repos/codemirror-vim/src/block-cursor.ts(BlockCursorPlugin.update()— vim-state-basedcaretColorinstead of DOM class check) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(added “Vim-state-based caretColor” subsection)
- Fork:
- 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
findKeyconsumed<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 exposessetIdleEscapeCallback(fn)which fires when Escape is pressed in idle normal mode, and (2) the plugin registers a callback (installEscapeGuard) that dismisses the popover viaHoverPopover.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(setIdleEscapeCallbackAPI,wasIdleNormalpre-capture infindKey) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(addedsetIdleEscapeCallbackAPI section) - Plugin:
src/vim/escape-guard.ts(NEW —installEscapeGuardwithHoverPopover.hide()dismissal) - Plugin:
src/main.ts(installEscapeGuard(this.app)call in feature registration)
- Fork:
- 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 (globalsetCursorSuppressed(true)), resulting in no visible cursor. Fixed by detecting editors inside.popoveror.modal-containerin theCursorControllerand un-suppressing the fork’s vim cursor for those views (setCursorSuppressedForView(view, false)). The animated cursortick()skips rendering for above-canvas editors. (#130)- Plugin:
src/vim/animated-cursor/controller.ts(isAboveCanvasflag, per-view un-suppression for popover/modal editors,tick()early return)
- Plugin:
- 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 ofconfig.enabled. Fixed by gating constructor suppression onconfig.enabledand callingclearCursorSuppressedForView()in the disabled early-return path. (#130)- Plugin:
src/vim/animated-cursor/controller.ts(constructor gates onconfig.enabled,update()clears per-view override when disabled)
- Plugin:
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Updated cursor shapes section withaddSettingTab()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-basedcaretColor,setIdleEscapeCallbackAPI)CONTRIBUTING.md: Addedescape-guard.tsto codebase structure; updatedcontroller.tsdescription withisAboveCanvasflag and popover/modal fallbackdocs/features/animated-cursor.md: Updated embeddable editors section with popover/modal fallback and z-index explanation- Fork
DIFFERENCES.md: AddedsetIdleEscapeCallbackAPI section; added “Vim-state-based caretColor” subsection; updatedfindKeyEscape 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/lwithout entering the cell editor. Supports structural commands (o/O,dd,dc,J/K,H/L,I/A,=) and cell editing entry viai/a/c/s/Enter. Escape exits table-nav. Fork-only feature.- Plugin:
src/vim/table-nav-controller.ts(KeyScope-based interception, freshcmTile.widgetreferences, 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)
- Plugin:
Changed
table-cell-cursor-guard.ts— now checksisTableNavActive()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 ontableWidgetMode === 'native'only, independent ofenableTableNav. Previously required bothenableTableNavandtableWidgetMode === 'native'. This enables a third usage mode: native table editor with vim cell editing and cross-cell navigation, without the table-nav overlay. TheenableTableNavsetting now controls only the nav overlay and structural motions (]|/[|,]c/[c).- Plugin:
src/main.ts(bothonloadandreloadFeaturespaths — removedenableTableNavfromapplyTableCellMotionsgate) - Plugin:
src/settings.ts(updatedenableTableNavdescription in both declarative and imperative settings UI)
- Plugin:
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()calledplaceCursorAround()beforedestroyTableCell(), 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 deferplaceCursorAround()torequestAnimationFrameso Obsidian’s teardown handlers finish before the final cursor placement.- Plugin:
src/vim/table-nav-controller.ts(exitTable— destroy-before-place, deferred cursor placement viawindow.requestAnimationFrame)
- Plugin:
- 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/lat 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 registeringh/j/k/lkey handlers on thecellEditScope(ObsidianScope). 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— addedh/j/k/lhandlers that checkisVimIdleand callexitCellEditToNav+navigate)
- Plugin:
- 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 staletrueoverride through plugin recreation.cellEditorCursorGuard.update()force-unsuppressed the cell cursor on every update cycle (same anti-pattern removed fromCursorControllerin commit 62444df). All unsuppress paths now useclearCursorSuppressedForView()(which removes the per-view override, falling back to global state) instead ofsetCursorSuppressedForView(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 whencursorInTableis true;update()usesclearCursorSuppressedForViewwhen leaving table;cellEditorCursorGuard— removed per-updatesetCursorSuppressedForView(cellView, false)force-unsuppress;destroy()usesclearCursorSuppressedForViewfor parent) - Plugin:
src/vim/table-nav-controller.ts(enterCellEdit,exitTable,destroy— all useclearCursorSuppressedForViewinstead ofsetCursorSuppressedForView(view, false)) - Plugin:
src/vim/bundled-vim.ts(exposedisCursorSuppressedForViewonCodeMirrorAdapterbridge for test access)
- Plugin:
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 inbeforehooks so cell editor introspection tests can accesseditMode.tableCelldirectly - Fixed
spike-table-nav-overlayeditorInfoField test: useobsidian.editorInfoFieldfromexecuteObsidiancallback instead ofwindow.require('obsidian')
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Added cursor-snap fix; updated table widget section with 3-mode table; updated cross-cell motion decouplingAGENTS.md: Updatedtable-nav-controller.tsdescription with deferred exit flow; updated table-cell-motions gatingCONTRIBUTING.md: Updatedtable-nav-controller.tsandtable-cell-motions.tsdescriptionsREADME.md: Updated table editing feature description with 3 mode combinationsdocs/features/tables.md: Updated table widget section with 3-mode architecture; clarified enableTableNav vs cross-cell motionsdocs/configuration/settings.md: UpdatedenableTableNavdescription
[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’sCursorControllerwith the previous cell’s screen position via theAnimatedCursorManagersingleton. The handoff infrastructure is in place but the canvas transition animation is not visible due to CSS stacking contexts (the canvas atposition: fixedon.app-containerrenders 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(CellCrossingHandoffinterface,createCrossingToken/storeCrossingHandoff/consumeCrossingHandoffmethods,signalCellCrossing/getPendingCrossingToken/clearPendingCrossingTokenmodule-level functions) - Plugin:
src/vim/animated-cursor/controller.ts(cellTransitionActiveflag, 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 inscheduleCrossing) - 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)
- Plugin:
Changed
- Table editing: migrated to native Obsidian table editor — the plugin no longer suppresses Obsidian’s
cm-table-widgetor 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 viaregisterEditorExtension(). ThetableWidgetModesetting 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(RangeSetBuildermonkey-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(nativeTableEditorAPI 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)
- Removed:
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
Scopekeymap 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 checkedisVimIdle()(which returnstrueduring hint mode, since hint mode is a plugin-level overlay, not a vim state) and calledonEscape(). Fixed by adding a guard in the embedded editor’s Escape handler that checksisHintModeActive(),isEasyMotionActive(), andisFlashActive()before evaluatingisVimIdle(). (#126)- Plugin:
src/editors/embeddable-editor.ts(Scope Escape handler — modal overlay active guard beforeisVimIdle()check)
- Plugin:
- 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. ThecellBrToNewline/cellNewlineToBrutilities 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’seventObservers.keydownfrom consuming them as cursor movement, then manually feed the event to Obsidian’s keymap system viaapp.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 unofficialKeymap.onKeyEventAPI fromobsidian-typings. (#120)- Plugin:
src/vim/table-nav-controller.ts(handleTableNavKey—stopPropagation+app.keymap.onKeyEventfor modifier combos)
- Plugin:
- 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 likeh,j,k,l,a,i,care no longer intercepted by the table-nav handler. The handler now checksadapter.state.dialogand returns early when a dialog is active. (#120)- Plugin:
src/vim/table-nav-controller.ts(handleTableNavKey—adapter.state.dialogcheck)
- Plugin:
- 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.jsonbut never pushed tovim.setOption()during initialization, so they silently reverted to defaults on every Obsidian restart. The vimrc/Lua code path was unaffected because it callsvim.setOption()directly. Additionally, on Obsidian 1.13+, the declarative settings system (setControlValue) did not forward any of the 9 vim engine settings tovim.setOption()— only the pre-1.13 imperativeonChangehandlers did. (#125)- Plugin:
src/main.ts(added init sync for insertmodeescape, insertmodeescapetimeout, operatorshadowtimeout, tabstop, shiftwidth, expandtab afterregisterVimOptions()) - Plugin:
src/settings.ts(VIM_OPTION_KEYSstatic set;setControlValueforwards vim engine settings tovim.setOption()withsetClipboardOption/setTextwidthside effects)
- Plugin:
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.mdKNOWN_LIMITATIONS.md: Added hint mode Escape fix to textarea vim Escape behavior section (#126)AGENTS.md: Updated embeddable-editor description with modal overlay active guardCONTRIBUTING.md: Updated embeddable-editor description with modal overlay active guarddocs/features/hint-mode.md: Added note about embedded editor Escape isolationKNOWN_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 sectionCONTRIBUTING.md: Addedtable-cell-motions.tsandtable-cell-cursor-guard.tsto codebase structure; updatedmanager.tswith cross-cell handoff API; updatedcontroller.tswith cell transition architectureAGENTS.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 limitationdocs/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-widgeteditors
[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’seventObservers.keydownfrom processing them as cursor movement commands (e.g.,<C-p>mapped to cursor-up). Previously, pressingCtrl+Pin cell selection mode moved the cursor up instead of opening the command palette. ThestopPropagation()blocks the event from reaching vim’s observer on the CM6 editor element while allowing Obsidian’s hotkey system (which uses Electron’sbefore-input-event) to handle it. (#120)- Plugin:
src/vim/table-nav-controller.ts(handleTableNavKey—stopPropagation()for modifier key combos)
- Plugin:
[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
setTableWidgetCellClickHandlercallback, coordinated betweentable-render-widget.tsandtable-nav-controller.ts. (#120)- Plugin:
src/vim/table-render-widget.ts(setTableWidgetCellClickHandler, click handler intoDOMfor embedded mode) - Plugin:
src/vim/table-nav-controller.ts(constructor registers callback;pendingClickCellfield for deferred cell selection on table-nav entry)
- Plugin:
Fixed
- Embedded table: click-outside handler exits table-nav during modal interaction — the capture-phase
mousedownlistener now checks for.modal-containerin the DOM before callingexitTable(). 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)
- Plugin:
- 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 bycheckEntry(). 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—hasDataRowcheck beforeenterTableNav)
- Plugin:
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-latestdevDependency — added community-maintained type definitions for Obsidian’s internal APIs. Replaces ~50 unsafeas unknown ascasts across 13 source files with properly typed access toeditor.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-latestdevDependency) - 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)
- Plugin:
Fixed
- Obsidian native highlights not cleared on Escape — Obsidian’s
is-flashinghighlights (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 unofficialeditor.removeHighlights('is-flashing')API (documented in obsidian-typings, used by obsidian-quiet-outline and others). (#122)- Plugin:
src/vim/mode-tracker.ts(clearNativeHighlightsmethod;vim-keypresshandler clearsis-flashingon<Esc>in normal mode)
- Plugin:
- 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 like2ysiw*. Previously, the chord disappeared after the surround sub-state was entered (e.g.,ysshowed correctly butysiwas blank). (#123)- Fork:
~/Repos/codemirror-vim/src/vim.js(processAction— saves and restoresvim.statusaroundclearInputStatewhenvim.surroundStateis pending;handleSurroundSubState— clearsvim.statuson surround completion) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(added “Chord display preservation during surround sub-state” section)
- Fork:
- Embedded table: table-nav key handler suppressed during modals —
handleTableNavKeyandhandleCellEditKeynow check for.modal-containerin 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 (aentered cell edit,ssubstituted, etc.). (#120)- Plugin:
src/vim/table-nav-controller.ts(handleTableNavKeyandhandleCellEditKey— modal container check)
- Plugin:
- 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(tableCursorGuardtransaction filter, separated fromPrec.highStateField)
- Plugin:
Tests
- 2 e2e tests in
test/specs/native-highlight-escape.e2e.ts(issue #122): Escape in normal mode clearsis-flashinghighlight, 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.mdKNOWN_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-latestto environment & tooling; updated mode-tracker description withclearNativeHighlightsCONTRIBUTING.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
jat 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\nwhen table is on last line instead of dispatching todoc.length)
- Plugin:
- 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—handledflag gatespreventDefault/stopPropagation;pendingDdefault case propagates instead of consuming)
- Plugin:
- Embedded table: which-key popups in cell selection mode — a
WhichKeyOverlayinstance 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-exportssetTableNavWhichKeyConfig) - Plugin:
src/main.ts(wiresembeddedWhichKeyConfigto table-nav controller)
- Plugin:
- Embedded table: picker focus stays on table widget — the
setActiveLeafoverride inembeddable-editor.tsnow allows focus transfer when a modal is open by checking for.modal-containerin the DOM. (#120)- Plugin:
src/editors/embeddable-editor.ts(setActiveLeafoverride — modal container check)
- Plugin:
- Embedded table: clicking outside table does not exit table-nav — a capture-phase
mousedownlistener onactiveDocumentexits table-nav when clicks land outside the widget. (#121)- Plugin:
src/vim/table-nav-controller.ts(installClickOutsideHandler/removeClickOutsideHandler)
- Plugin:
- Embedded table: stale table-nav state after document replacement — the ViewPlugin’s
update()now detects whendocChangedfires 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 ondocChanged)
- Plugin:
- Embedded table: cursor displacement when entering table-nav —
setActiveEditTableRange()is now called beforethis.view.dispatch()inenterTableNav(), preventing decoration rebuild from snapping the cursor to the header row. (#121)- Plugin:
src/vim/table-nav-controller.ts(enterTableNav— reorderedsetActiveEditTableRangebefore dispatch)
- Plugin:
- Embedded table: Escape in table-nav uses Obsidian Scope — table-nav mode now installs an Obsidian
Scopewith 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)
- Plugin:
Tests
- Unblocked 13 previously-skipped embedded table widget e2e tests in
test/specs/table-cell-vim-mode.e2e.ts— root cause: missingbrowser.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:jat 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:jmoves through proper table rows, cursor not stuck on header, cursor not jumping back
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Marked 7 table widget sub-issues as fixed (#119, #120, #121); updated embedded table e2e test coverage noteAGENTS.md: Updated table-nav-controller description with click-outside handler, Scope-based Escape, which-key in table-nav, stale state cleanupCONTRIBUTING.md: Updatedtable-nav-controller.tsdescription
[0.106.0] - 2026-08-11
Fixed
- Bundled
table/table3snippets missing trailing newline — the$0final 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$0as a separate final body element, matching the pattern used by theFrontmattersnippet. (#118)- Plugin:
src/snippets/bundled/obsidian-markdown.json(Table 2x2,Table 3x3— moved$0from inline on last row to standalone final element)
- Plugin:
- 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()inSnippetRegistryinserted user entries before bundled entries but never removed the bundled entry, and entry IDs were source-qualified (bundled:Table 2x2vsuser:My Table) so both coexisted in theentriesMap. 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(sourcePrioritystatic method;addToPrefixIndexrewritten with priority-based filtering, orphan cleanup, and priority-sorted insertion)
- Plugin:
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):tablesnippet trailing newline via Tab,table3snippet trailing newline via:snippet, table at end of document produces content after last row, user override expands correct body,lookupByPrefixreturns only user entry,getAllexcludes shadowed bundled entry, non-overridden bundled snippet still works
Documentation
CHANGELOG.mdCONTRIBUTING.md: Addedregistry.tsto snippets codebase structure with priority-based override descriptiondocs/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)
activeEditTableRangewas cleared beforecloseCellEditor/tableRealigndispatches, causingbuildDecorationsto create aDecoration.replacefor the active table and displacing the cursor — fixed by keepingactiveEditTableRangeset throughout the exit and refresh lifecycle. (2) AftertableRealign,doRefreshAfterOpusedArray.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 prematuresetActiveEditTableRange(null)clear;doRefreshAfterOp— setsactiveEditTableRangebeforetableRealigndispatch, replacesArray.findwith nearest-match loop for post-realign table re-location)
- Plugin:
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.mdKNOWN_LIMITATIONS.md: Updated multi-table fix withactiveEditTableRangelifecycle andArray.findnearest-match fixesCONTRIBUTING.md: Updatedtable-nav-controller.tsdescriptiondocs/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(WhichKeyConfigexported 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 viasetTimeout(0);closeCellEditor()which-key cleanup before editor destroy) - Plugin:
src/vim/textarea-vim-manager.ts(whichKeyConfigclass field;updateOptions()extended with which-key config;ActiveReplacement.whichKeyfield;replace()deferred which-key creation with.view-content→.modal-containerfallback;teardownActive()which-key cleanup) - Plugin:
src/main.ts(WhichKeyConfigimport;setCellEditorWhichKeyConfigimport; embedded config construction and wiring afterWhichKeyOverlaycreation)
- Plugin:
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()intable-nav-controller.tsqueried all.vim-table-renderedelements and returned the first match without considering whichTableRangethe cursor was in. Fixed by adding atableFromparameter tofindWidgetEl()and using CM6’sview.posAtDOM()to correlate each widget element with its document position, returning the nearest match to the active table’sfromoffset. (#117)- Plugin:
src/vim/table-nav-controller.ts(findWidgetEl— accepts optionaltableFromparameter, usesposAtDOMnearest-match withtry/catchfor detached elements;enterTableNav— passestable.fromexplicitly;devAssertimport and__DEV__assertion verifying widget position matches active table)
- Plugin:
- 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)
- Plugin:
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 whenwhichKeyModeis 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.mdKNOWN_LIMITATIONS.md: Marked multi-table widget selection as fixed withposAtDOMposition matching; marked Enter-in-cell-editor table breakage as fixed with<br>conversion; added which-key in embedded editors note to table cell sectionCONTRIBUTING.md: Updatedtable-nav-controller.tsdescription withposAtDOM-based widget matching for multi-table support; updatedtable-utils.tsdescription withcellBrToNewline/cellNewlineToBrhelpers; updatedwhich-key.ts,table-cell-editor.ts, andtextarea-vim-manager.tsdescriptions with which-key overlay lifecycleAGENTS.md: Updated dual-vim architecture section with which-key in embedded editors via dependency injectiondocs/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 sectiondocs/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
vimHandleKeystest helper — new helper intest/helpers.tsthat dispatches all keys synchronously throughVim.handleKey()in a singleexecuteObsidiancallback, bypassing DOM event timing. Used for visual-mode compound operations that fail withvimRawKeysDOM dispatch.- Plugin:
test/helpers.ts(vimHandleKeysfunction)
- Plugin:
useHandleKeyflag onTestCaseDefinition— test cases can opt in tovimHandleKeysdispatch viauseHandleKey: true.testWithNeovimchecks this flag and routes tovimHandleKeysinstead ofdispatchVimKeys.- Plugin:
test/neovim/test-definitions.ts(useHandleKey?: booleanon interface, set on 6 visual-mode test cases) - Plugin:
test/neovim/test-wrapper.ts(importvimHandleKeys,useHandleKeyin config type, dispatch branching)
- Plugin:
- Deviation category classification — all deviations in
deviations.tsnow have acategoryfield (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)
- Plugin:
- Golden schema extended with
registersandvisualMode—GoldenCase.resultnow includes optionalregisters(unnamed register text + linewise flag) andvisualMode(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(EditorStateextended,getObsidianState/getNeovimStatecapture registers and visual sub-mode) - Plugin:
test/neovim/golden.ts(GoldenCaseextended) - Plugin:
test/neovim/record-golden.ts(captures registers and visual mode) - Plugin:
test/neovim/client.ts(getRegisterType,getRawModemethods) - Plugin:
test/neovim/golden-data/*.json(24 files re-recorded with new fields)
- Plugin:
- Golden mode comparison —
testWithNeovim()golden path now comparesmodein addition tocontentandcursor. Mode mismatches that were previously invisible are now caught.- Plugin:
test/neovim/test-wrapper.ts(mode comparison in golden path)
- Plugin:
else { throw }guards on all 26SUITES.find()files — if a suite name is renamed intest-definitions.tsbut 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)
- Plugin:
Changed
- E2E test assertions strengthened — 35 tests that previously only checked
mode === 'normal'orassertPluginLoaded()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
- Plugin:
- 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 →\x1bbyte). 1 reclassified frominfra-limitationtoupstream-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/Tlabels only appeared in the bottom half of the viewport. The number of missing lines matched the frontmatter line count. Root cause:getVisibleRange()insrc/easymotion/targets.tsusedview.lineBlockAtHeight()which relies on CM6’s height map — when the collapsed frontmatter widget was off-screen, height estimation errors causedcoordsAtPos()to returnnullfor targets near the viewport top, andfilterVisibleTargets()dropped them. Fixed by usingview.visibleRanges(actually-rendered document ranges) instead oflineBlockAtHeight. Also affected EasyMotion target scanning. (#114)- Plugin:
src/easymotion/targets.ts(getVisibleRange— replacedlineBlockAtHeightwithview.visibleRanges) - Plugin:
test/unit/flash-targets.test.ts(updated CM6 stub to providevisibleRanges)
- Plugin:
v$dcursor off-by-one — visual-modev$dleft cursor at ch:5 instead of ch:4 after deleting to end of line. Root cause:clipCursorToContentin the delete operator ran whilevim.visualModewas stilltrue(allowingch = text.length), andexitVisualModeran after the operator returned without re-clamping. Fixed by re-clippingoperatorMoveTothroughclipCursorToContentafterexitVisualModeinapplyOperator.- Fork:
~/Repos/codemirror-vim/src/vim.js(applyOperator— re-clip cursor afterexitVisualMode) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(added “Visual operator cursor re-clamping after exitVisualMode” section)
- Fork:
ssubstitute consumed by flash jump in Tier 1 tests — theskey did nothing innormal-editing.e2e.tsbecause the test vault hadflashJumpEnabled: true, which mappedsto flash jump mode instead of the built-in substitute (cl). Flash jump tests explicitly enable this setting in their ownbefore()hooks. Fixed by settingflashJumpEnabled: falseindata.jsonand adding a defensive disable in the test’sbefore()hook.- Plugin:
test-vault/.obsidian/plugins/vim-motions/data.json(flashJumpEnabled: false) - Plugin:
test/specs/vim-builtin/normal-editing.e2e.ts(defensive flash disable inbefore())
- Plugin:
vt.don multi-dot content consumed by flash labels —vt.don content with 2+ dot characters (e.g.,foo.bar.baz) deleted only 1 character because flash motions showed labels for the multiple.matches, consuming thedkey as a label character. Not a fork bug — flash working as designed (same as flash.nvim in Neovim). Fixed by settingenableFlash: falsein test vaultdata.jsonand disabling flash invisual-mode.e2e.tsbefore()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 inbefore())
- Plugin:
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.mdKNOWN_LIMITATIONS.md: Added “E2E test infrastructure weaknesses” section with fixed/remaining items; markedssubstitute test failure as fixed; updated deviation-masked operations count and root causes; marked flash frontmatter viewport offset as fixedAGENTS.md: Updated deviation registry description with categories and[INFRA-SKIP]; updated test helpers withvimHandleKeys; updatedtargets.tsdescription withvisibleRangesCONTRIBUTING.md: AddedvimHandleKeysto helper list; addeduseHandleKeyflag documentation; updated deviations.ts description with categories; updatedtargets.tsdescription withvisibleRangesDIFFERENCES.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
findKeyguard (commit4aa1cc7) 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 originalkey.length === 1check), 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 becausehandleEsc()returnsundefinedin 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 returnundefinedfromfindKey, 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)
- Fork:
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Updated v0.99.0<Space>fix description — narrowed guard to prevent consuming functional keysAGENTS.md: Updated codemirror-vim fork description with narrowed key consumption guard
[0.102.0] - 2026-08-08
Fixed
- Vim
pwith non-text clipboard content silently does nothing — whenclipboard=unnamedorclipboard=unnamedplusis set, pressingp(or]p,[p,:put) with an image on the system clipboard did nothing. The fork’spasteaction callednavigator.clipboard.readText()which returns""for image-only clipboard content, andcontinuePaste()bailed on the empty string with no error handling. Fixed by adding a.catch()handler to thereadText()promise and afallbackToNativePaste()method that callsdocument.execCommand('paste')when the text clipboard is empty orreadText()rejects. This triggers Obsidian’s native paste pipeline, which creates an attachment and inserts![[Pasted image …]]. AprogrammaticPasteflag suppresses the fork’sgetOnPasteFnlistener during the fallback to prevent spurious insert-mode entry. The editor stays in normal mode after the fallback. Coversp,]p,[p,:put, and explicit"+pregister paste.P/gp/gPare overridden by the host plugin’spasteFromRegister()and are not affected (separate issue).- Fork:
~/Repos/codemirror-vim/src/vim.js(programmaticPasteflag,getOnPasteFnguard,pasteaction rewrite with.catch(),fallbackToNativePastemethod) - Fork:
~/Repos/codemirror-vim/src/types.ts(fallbackToNativePastetype signature invimActions)
- Fork:
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), vimpwith image clipboard andclipboard=unnamedinserts image via native fallback
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Added clipboard non-text paste fallback fix to yank-ring sectionAGENTS.md: Updated codemirror-vim fork description withfallbackToNativePasteandprogrammaticPasteflagdocs/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()calledteardownActive()synchronously inside the ObsidianScope.registerhandler, which destroyed the editor and popped the keymap scope mid-handler. TheisolateKeyEventsstopPropagation()handler was removed with the editor, so the DOM-level Escape continued propagating to parent elements. Fixed by deferring teardown viarequestAnimationFrame— the Scope handler returnstrue(consuming the event) while the editor’s scope is still on the stack. Also added a_destroyingflag toembeddable-editor.tsto prevent the blur event listener from double-popping the keymap scope whendestroy()is already handling cleanup. (#112)- Plugin:
src/vim/textarea-vim-manager.ts(handleEscapeAndRedispatch— deferred teardown viarequestAnimationFrame) - Plugin:
src/editors/embeddable-editor.ts(_destroyingflag, blur handler guard)
- Plugin:
- Escape exit from table cell editor leaks to parent scope — same scope-pop-mid-handler vulnerability as the textarea vim overlay. The
onEscapecallback intable-nav-controller.tscalledexitCellEdit()→closeCellEditor()→editor.destroy()→popKeymapScope()synchronously inside the Scope handler. Fixed with the same deferred-teardown pattern. (#112)- Plugin:
src/vim/table-nav-controller.ts(onEscapecallback — deferredexitCellEditviarequestAnimationFrame)
- Plugin:
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 tomodal.containerEl, modal stays open, leaf ID unchanged
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Updated embedded editor Escape handler description with deferred teardown pattern,_destroyingguard, and scope-pop-mid-handler fixCONTRIBUTING.md: Updatedembeddable-editor.tsdescription with_destroyingguard; updatedtextarea-vim-manager.tsdescription with deferred teardown; updatedtable-nav-controller.tsdescription with deferredonEscapeAGENTS.md: Updated dual-vim architecture section with deferred teardown and_destroyingguard
[0.100.0] - 2026-08-07
Fixed
:snippetvisual selection not captured — running:snippet <name>from visual mode now correctly wraps the selected text. Previously,$TM_SELECTED_TEXT/$VISUALresolved to empty and the snippet was inserted at the cursor instead of replacing the selection. Root cause: vim’s ex-command dispatcher callsexitVisualMode()before the:snippethandler runs, collapsing the CM6 selection. Fixed by reading the visual selection from vim’s'<'/'>'marks (which surviveexitVisualMode) via thecmadapter parameter, extracting the text withcm.getRange(), and overridingctx.selectedTextbefore preprocessing. Visual line mode (V) normalizes the start toch: 0and extends the end to full line length. (Discussion #108)- Plugin:
src/snippets/commands.ts(recoverVisualSelection()— reads'<'/'>'marks +lastSelection.visualMode/visualLineflags;:snippethandler overridesctx.selectedTextand uses mark-derivedfrom/tooffsets for theapply()range)
- Plugin:
Tests
- 10 e2e tests in
test/specs/spikes/spike-snippet-visual-surround.e2e.ts(Discussion #108):$TM_SELECTED_TEXTwraps word selection viaviw, fills tabstop default in link snippet,$VISUALalias parity, multiline callout wrapping, empty selection regression guard, link snippet structure, Luafmt()wraps selection, Luat()/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-lineVjjwraps three lines, charwisevacross lines, visual block mode (<C-v>) produces non-empty text, stalelastSelectionuses marks (vimgvsemantics), 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.mdKNOWN_LIMITATIONS.md: Updated snippet variable limitations section —$TM_SELECTED_TEXT/$VISUALtab-expand limitation clarified, visual-mode:snippetnow worksCONTRIBUTING.md: Addedcommands.tsto snippets codebase structure with visual selection recovery descriptiondocs/features/snippets.md: Updated$TM_SELECTED_TEXT/$VISUALcallout to confirm visual-mode:snippetworks
[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 viaset 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 fullpcreoption (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: booleaninVimMotionsSettings,pcre: trueinDEFAULT_SETTINGS, toggle in both declarative and imperative settings UI — General page, Vim engine group) - Plugin:
src/vimrc/loader.ts(pcreadded toKNOWN_SET_OPTIONSandKNOWN_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(pcreadded tonewOptionstest array)
- Plugin:
- 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.$CLIPBOARDuses a cache-ahead pattern (refreshed onwindow focusandvisibilitychange) to avoid making the synchronous snippet pipeline async. On mobile,$CLIPBOARDresolves to empty due to browser clipboard API restrictions.$TM_SELECTED_TEXT/$VISUALresolve 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— addedcurrentLine,currentWord,lineNumber,lineIndex,workspaceNamefields) - Plugin:
src/snippets/variables.ts(added 15 new variable entries includingVISUAL,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; addedpad3()andgetTimezoneName()helpers) - Plugin:
src/main.ts(_clipboardCachefield,refreshClipboardCache()method, clipboard cache listeners onwindow focus+visibilitychange+ initial population;getSnippetPreprocessContext()rewritten to populate all fields from the active editor including selection, current line/word, line number, and workspace name)
- Plugin:
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 emptymotionArgs, so the fork treated them as exclusive —y<leader><leader>fk{label}excluded the targetkfrom the yank. Visual mode was unaffected (it extends the selection directly without consulting theinclusiveflag). Backward motions (F,T) remain exclusive, matching native Vim. (#109)- Plugin:
src/easymotion/register.ts(EasyMotionDefinterface — addedmotionArgs?: Record<string, unknown>;EASYMOTION_DEFS— addedmotionArgs: { inclusive: true }to 8 of 17 defs matching Vim’s native inclusivity; registration loop — passesdef.motionArgs ?? {}tomapCommand)
- Plugin:
- 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-containerDOM element exists but is hidden (display: none). The fork’sfocusBeforecallback found the hidden element viaquerySelector, focused it (no visible effect), andmoveByLines/moveByDisplayLinesreturned the original cursor position — leaving the cursor stuck. Fixed by adding asetPropertiesSource(fn: () => boolean)API to the fork, parallel tosetLivePreviewField. When the callback returnstrue, 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(setPropertiesSourceAPI,_propertiesSourceFngate infindPosV) - Fork:
~/Repos/codemirror-vim/src/index.ts(exportsetPropertiesSource) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(addedsetPropertiesSourceAPI section, updated “Properties navigation” section with two-level gate) - Plugin:
src/vim/bundled-vim.ts(createBundledVimExtensionacceptsisPropertiesSourcecallback, callssetPropertiesSource) - Plugin:
src/main.ts(passespropertiesInDocument === 'source'callback) - Plugin:
src/types/codemirror-vim.d.ts(addedsetPropertiesSourcetype declaration)
- Fork:
- Escape in operator-pending mode exits embedded text area editor — pressing
dthenEscapein the textarea vim overlay exited the editor instead of clearing the pending operator. The Escape handler checkedvim.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’seventObservers.keydowncallede.preventDefault()before CM6 keymaps processed the event. Fixed by moving Escape handling to an ObsidianScope.registerhandler (fires before vim’s observer) with a newisVimIdle()check covering all compound-command sub-states. (#112)- Plugin:
src/editors/embeddable-editor.ts(isVimIdlehelper,VimIdleStateinterface, Scope-based Escape handler replacing CM6 keymap handler)
- Plugin:
- 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
keydownevents to the parent modal, triggering unintended actions in third-party plugins (e.g., Spaced Repetition). Fixed with a new opt-inisolateKeyEventsoption onEmbeddableEditorOptionsthat stopskeydownandkeyuppropagation via CM6domEventHandlers. Only enabled for textarea-vim overlays; Oil and table-cell editors are unaffected. (#112)- Plugin:
src/editors/embeddable-editor.ts(isolateKeyEventsoption,domEventHandlerswithstopPropagation) - Plugin:
src/vim/textarea-vim-manager.ts(isolateKeyEvents: true)
- Plugin:
- 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’sfindKeyusedkey.length === 1to suppress unmatched single-character keys in normal mode, butvimKeyFromEventconverts Space to"<Space>"(7 characters) via thespecialKeymap, bypassing the guard. The function returnedundefinedinstead of a consuming no-op, letting the keydown propagate to CM6’s text input handler. Fixed by replacing the guard withkey.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)
- Fork:
Tests
- 4 e2e tests in
test/specs/easymotion-comprehensive.e2e.ts(issue #109): inclusivefyank includes target character, inclusiveedelete includes end-of-word character, exclusivewyank excludes target (regression), visual modefyank includes target (regression) - 1 unit test in
test/unit/known-set-options.test.ts(issue #111):pcreoption registered inKNOWN_SET_OPTIONSwith 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 ($VARand${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 liveWelcome.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 viagetSnippetPreprocessContext()(3 tests), combined multi-variable expansion (1 test) - 3 e2e tests in
test/specs/vim-builtin/g-commands.e2e.ts(issue #77):kmoves up through source-rendered frontmatter,knavigates through multiple frontmatter properties,gkmoves up through source-rendered frontmatter. Tests setpropertiesInDocumentto'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):isVimIdlecoverage 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.mdKNOWN_LIMITATIONS.md: UpdatedKNOWN_CM_VIM_OPTIONSlist insetoption scope section to includepcre; added snippet variable limitations section ($CLIPBOARDmobile restriction,$TM_SELECTED_TEXTtab-expand limitation,snip.envdeferred, 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 (linewisej/k,motionArgs.forward/clipToLine,EXTRA_DEFSbidirectional motions,easyMotionRepeatoperator-pending)CONTRIBUTING.md: Updatedvariables.tsdescription in codebase structure; updatedbundled-vim.tsdescription withsetPropertiesSourcewiring; updatedeasymotion/register.tsdescription with per-motionmotionArgsfor operator-pending inclusivityREADME.md: Updated Snippets feature line with variable count and vim-ecosystem aliasesAGENTS.md: Updated codemirror-vim fork description withsetPropertiesSourceAPIdocs/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 behaviordocs/features/easymotion.md: Updated operator-pending section with inclusivity semantics; fixed stale dot-repeat notedocs/configuration/vimrc.md: Addedpcrerow to boolean options tabledocs/configuration/settings.md: Addedpcrerow to Vim engine settings tabledocs/configuration/lua-config.md: Addedpcrerow tovim.optoptions tableDIFFERENCES.md(fork): AddedsetPropertiesSourceAPI section, updated “Properties navigation” section with two-level gateKNOWN_LIMITATIONS.md: Updated embedded editor Escape handler description with Scope-based approach andisVimIdlesub-state detection; added key event isolation noteCONTRIBUTING.md: Updatedembeddable-editor.tsdescription withisVimIdlehelper, Scope-based Escape, andisolateKeyEventsoptionAGENTS.md: Updated dual-vim architecture section with Scope-based Escape handling for embedded editorsDIFFERENCES.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 thecoordsAtPos()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()viaview.domAtPos()) for baseline calculation, falling back tocoordsAtPos()when the DOM rect is unavailable. (#106)- Plugin:
src/vim/animated-cursor/renderer.ts(BlockCharInfo— addedcharTop/charHeightfields;drawCursorShapeanddrawSmearCursor— baseline anchored to DOM char rect when available) - Plugin:
src/vim/animated-cursor/controller.ts(resolveBlockChar— extracts DOM character bounding rect viaRange.getBoundingClientRect())
- Plugin:
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Marked animated cursor tall-line character displacement as fixedCONTRIBUTING.md: Updatedrenderer.tsdescription with DOM-based baseline calculationAGENTS.md: Updatedrenderer.tsdescription in animated cursor codebase structuredocs/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 CM6eventObservers.keydown(DOM event observer) instead ofeventHandlers.keydown— in CM6’s dispatch order, observers run before handlers, guaranteeing vim processes keys first regardless ofPrecordering orcommunity-plugins.jsonorder. Previously, both the fork and Latex Suite registered keydown handlers atPrec.highest, and the first-registered handler won — making key handling dependent on which plugin loaded first. (#107)- Fork:
~/Repos/codemirror-vim/src/index.ts(movedkeydownfromeventHandlerstoeventObservers, addedsetKeyInterceptActiveAPI) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(added “Observer-based keydown dispatch” and “setKeyInterceptActive API” sections) - Plugin:
src/flash/state.ts(setFlashActiveandcancelFlashcallsetKeyInterceptActive) - Plugin:
src/easymotion/register.ts(createMotionTriggerandcreateCharMotionTriggerbracket try/finally withsetKeyInterceptActive) - Plugin:
src/ui/hint-mode.ts(waitForHintKeysetssetKeyInterceptActiveon entry and cleanup)
- Fork:
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Updated Latex Suite interaction section with observer-based keydown dispatch fixAGENTS.md: Updated codemirror-vim fork description with observer-based keydown dispatch andsetKeyInterceptActiveAPIdocs/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/clientYcoordinates, 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 fromgetBoundingClientRect()and passing coordinates to allMouseEventandPointerEventdispatches. Also replacedel.click()with a coordinate-awareMouseEventdispatch. (#104)- Plugin:
src/ui/hint-mode.ts(getElementCenterhelper, coordinate injection inhintActivate— openInNewPaneMouseEvent, normal clickPointerEvents, andel.click()replacement)
- Plugin:
Added
- Hint mode right-click (context menu) action — new
gfbinding in non-editor views and Shift+label modifier in editor context to open the right-click context menu on any hint target. Dispatches acontextmenuMouseEventwith proper coordinates fromgetElementCenter(). Also available as:hintcontextmenu(:hintco) ex command andvim-motions:hint-context-menuObsidian command. Shift key normalization inwaitForHintKey()ensures Shift-held characters match lowercase labels correctly. (#104)- Plugin:
src/ui/hint-mode.ts(hintContextMenuaction,contextMenuincreateHintAction/createHintActions,shiftKeyinHintResult, Shift→contextMenu upgrade in action selection,e.key.toLowerCase()normalization when Shift held),src/workspace/global-defaults.ts(gfbinding,contextMenuinhintActionsparameter type),src/main.ts(:hintcontextmenuex command,vim-motions:hint-context-menuObsidian command,contextMenuinhintActionstype)
- Plugin:
Tests
- 5 e2e tests in
test/specs/hint-mode.e2e.ts(issue #104):gffrom graph view shows hint overlay,gflabel dispatches contextmenu event with non-zero coordinates, Shift+label dispatches contextmenu in editor, Shift+label matches lowercase labels (case-sensitivity regression),hint-context-menuObsidian command registered
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Updated hint mode actions with context menu action, coordinate fix, and Shift modifierAGENTS.md: Updatedhint-mode.tsdescription withgetElementCenter,hintContextMenu, and Shift modifierCONTRIBUTING.md: Updatedhint-mode.tsdescriptionREADME.md: Updated Vimium-style hints feature line withgfdocs/features/hint-mode.md: Addedgfto non-editor keybinding table, Shift modifier to editor context,vim-motions:hint-context-menuto commandsdocs/reference/keybindings.md: Addedgfto non-editor view bindings tabledocs/features/ex-commands.md: Added:hintcontextmenuto 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), thecm-vim-linewise-widget-selectionbackground was overridden by the callout’s own styling. (2) When the cursor was inside the callout (unfolded as.cm-lineelements withHyperMD-quoteclasses), Obsidian’s.HyperMD-quote { background-color: var(--blockquote-background-color) }rule overrode thecm-vim-linewise-selectionbackground due to CSS cascade ordering. Fixed by increasing CSS specificity of the selection rules to (0,5,0) via.cm-editor .cm-scroller .cm-contentancestor chain, outranking Obsidian’s (0,4,0) blockquote rule without using!important. (#103)- Plugin:
styles.css(increased specificity on.cm-vim-linewise-selectionand.cm-vim-linewise-widget-selectionrules)
- Plugin:
- 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 theOBSIDIAN_SELECTORSlist. 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-switchertoOBSIDIAN_SELECTORS. (#104)- Plugin:
src/ui/hint-mode.ts(added.workspace-drawer-vault-switchertoOBSIDIAN_SELECTORS)
- Plugin:
- 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 fromsel.headin forward selections to render the cursor on the last selected character, but thech !== '\n'guard prevented the step-back whensel.headpointed 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)
- Plugin:
Documentation
CHANGELOG.mdKNOWN_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.vimrcor.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 aconfigOverridespersistence system: after vimrc/Lua loading, override values are captured and persisted indata.json. On next startup,configOverridesare 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 toreloadFeatures()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(replacedvimrcOverrides?.deletewithclearSettingOverrideacross all onChange handlers — covers both declarative and imperative paths, fixes missingluaOverridesdeletion)
- Plugin:
preVimrcSettingsshallow copy — nested objects (cursorShapes,modePrompts,pickerKeymap) shared references withthis.settings, causingsaveSettings()to accidentally persist overridden cursor shapes. Fixed with deep copy.- Plugin:
src/main.ts(line 677 — deep copy nested objects inpreVimrcSettingssnapshot)
- Plugin:
- Clipboard/textwidth falsely shown as “Set by vimrc” — the initial settings restoration at startup called
onSettingOverride()forclipboardandtextwidth, writing tovimrcOverrideseven without a vimrc file. Fixed by using direct side-effect calls.- Plugin:
src/main.ts(replacedonSettingOverridecalls with directsetClipboardOption/setTextwidthcalls)
- Plugin:
- 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 pressedEscto dismiss the modal, causingcommit()to hang permanently. Fixed by adding aresolvedguard —onClose()resolvesfalsewhen 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 callingview.focusEditor()on both the cancel and commit paths. (#100)- Plugin:
src/oil/manager.ts(OilConfirmModal—resolvedguard flag,onCloseresolves on Esc dismissal;commit—view.focusEditor()after confirm and cancel paths)
- Plugin:
:sortcursor 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.setCursorafterreplaceRange)
- Fork:
CTRL-V $ dcursor 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)
- Fork:
- Hint mode: modifier keydown event propagates to Obsidian handlers — pressing
Ctrlalone during hint mode could trigger Obsidian’s own key handlers because the modifier-only early return inwaitForHintKey()did not callpreventDefault()orstopPropagation(). 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 addinge.preventDefault()ande.stopPropagation()before the early return for modifier-only keys. (#98)- Plugin:
src/ui/hint-mode.ts(waitForHintKeyhandler —preventDefault+stopPropagationon modifier-only keydown)
- Plugin:
- Hint mode: count prefix (
2F) focus restoration races with async navigation — when using2Fon an internal link target,hintActivate()firednavigateWithJump()without awaiting it (viavoid), thensetActiveLeaf(originalLeaf)ran synchronously. The asyncopenLinkText()insidenavigateWithJumpcould 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 makinghintActivateasync and awaiting bothnavigateWithJump()andduplicateLeaf(). ThecreateHintActioncallback 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— returnsPromise<boolean>;createHintAction—asynccallback,await action(),Promise.resolve()wrappers for sync actions)
- Plugin:
Added
- 27 new vimrc/Lua configurable options — the following settings were previously only configurable via the Settings UI and are now available via
:setin vimrc andvim.optin 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 newKNOWN_SET_OPTIONSentries)
- Plugin:
- Runtime invariant system —
invariant()(always-on, type-narrowing) anddevAssert()(dev-only, stripped from production) helpers insrc/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:violationsex command.__DEV__build-time flag via esbuilddefineenables 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(defineoption),vitest.config.ts(defineoption)
- Plugin:
:violationsex command — displays accumulated invariant violations with timestamps.:violations!clears the violation log.- Plugin:
src/workspace/commands.ts(:violationsand:violations!registration)
- Plugin:
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 copytest/unit/mode-tracker.test.ts(11 tests): getDialogPrefix, resolveMode logictest/unit/dual-vim.test.ts(7 tests): bundled-vim lifecycle, bridge install/uninstall, invariant triggertest/unit/settings-resolution.test.ts(16 tests): DEFAULT_SETTINGS completeness, settings merge, configMode migration, signcolumn migration, idempotencytest/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,3sortcursor positioning,CTRL-V $ delete to EOLcursor 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):Fon 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.mdKNOWN_LIMITATIONS.md: Marked gutter vimrc/Lua reconfiguration as fixed; marked preVimrcSettings shallow copy as fixed; marked clipboard/textwidth false override as fixed; updatedsetoption scope section with configOverrides persistence; marked Oil focus loss after commit as fixeddocs/features/oil-explorer.md: Added focus retention after commit noteAGENTS.md: Updated hint mode page ownership withhinthotkeyREADME.md: Updated vimrc configurable settings countCONTRIBUTING.md: Added configOverrides persistence andclearSettingOverridehelper to conventions; updated vimrc loader descriptiondocs/configuration/vimrc.md: Added 27 new options to vimrc tables; updated override behavior section with configOverrides persistence and gutter restart notedocs/configuration/lua-config.md: Added 27 new options to vim.opt tableKNOWN_LIMITATIONS.md: Marked:sortcursor andCTRL-V $cursor deviations as fixedCONTRIBUTING.md: Added invariant system to codebase structure, updated testing instructions forbuild:devAGENTS.md: Updated manual testing instructions forbuild:dev, added:violationscommand, updated golden test count and deviation countREADME.md: Addednpm run test:unitto development commandsDIFFERENCES.md(fork): Added:sortcursor positioning and block visual delete cursor clamping sectionsdocs/features/ex-commands.md: Added:violationsand:violations!ex commandsKNOWN_LIMITATIONS.md: Updated hint mode modifier key fix withstopPropagation; updated count prefix focus fix with asynchintActivateAGENTS.md: Updatedhint-mode.tsdescription with asynchintActivateand modifierstopPropagation
[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()inhint-mode.tstreated modifier-only keydown events (wheree.keyis"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 ofwaitForHintKey()’s handler. (#98)- Plugin:
src/ui/hint-mode.ts(waitForHintKeyhandler — modifier-key guard beforepreventDefault)
- Plugin:
- 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()withopenInNewPane=truecallsnavigateWithJump()orduplicateLeaf(), both of which focus the new leaf. The nextrun(count-1)then showed hints on the new tab. Fixed by saving the original active leaf beforewaitForHintKeywhen count > 1, and restoring focus to it after each activation before scheduling the next round. (#98)- Plugin:
src/ui/hint-mode.ts(createHintAction—originalLeafcapture +setActiveLeafrestore before recursiverun)
- Plugin:
- Hint mode:
<leader><leader>hignores count prefix — thehintModeaction defined viadefineActiondid not acceptActionArgs, soactionArgs.repeat(the count from vim’s input state) was never passed toactivate(). Count prefix only worked in non-editor context (via global key handler). Fixed by accepting(_cm, actionArgs)and passingactionArgs.repeat. (#98)- Plugin:
src/main.ts(defineAction('hintMode')— acceptactionArgs, passrepeattoactivate)
- Plugin:
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,2Fkeeps 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, orcloseOil()),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()calledremoveChild(editor)which triggersunload()but notdestroy(). ThepopKeymapScopecall that removes the Oil-specific ObsidianScope(withCtrl+T/S/H/L/Chandlers) lives indestroy(), so the scope remained pushed on the keymap stack after Oil was gone — intercepting Ctrl keys and silently consuming them. Fixed by callingthis.editor.destroy()beforeremoveChild()inonClose(). (#93)- Plugin:
src/oil/oil-view.ts(onClose— explicitdestroy()beforeremoveChild)
- Plugin:
Tests
- 2 e2e tests in
test/specs/oil-poc.e2e.ts: Ctrl keys work after closing Oil viacloseOil()(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 registeringCtrl+T,Ctrl+S,Ctrl+H,Ctrl+L, andCtrl+Con the embeddable editor’s ObsidianScope(the same mechanism that already interceptsMod+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 thesetActiveLeafguard 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 viavim.defineExfor users who prefer typing them. (#93)- Plugin:
src/editors/embeddable-editor.ts(registerScopeKey()method onEmbeddableMarkdownEditorinterface andConcreteEmbeddableEditorclass — delegates to the internal ObsidianScope),src/oil/oil-view.ts(registerOilScopeKeys()— registers 5 Ctrl-key combos on the editor scope with blur-before-navigate for cross-leaf actions)
- Plugin:
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_TIMEOUTfiredresetSequence()unconditionally, dismissing the popup even when partial completions existed. In editor mode, the which-key overlay stays open until the command completes (driven byvim-keypress/vim-command-doneevents, 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 beforeresetSequence)
- Plugin:
-
gtalways navigates to first tab instead of next tab in non-editor views — pressinggtwithout 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 usedthis.count || 1, making count 0 (no count typed) indistinguishable from count 1 (user typed1gt). Thegthandler’sif (count > 0)always triggeredgotoNthTab(app, 1). Fixed by passingthis.countdirectly tobuiltinhandlers, letting each handler decide its own default. Thegthandler already had the correct branching (count > 0→ nth tab, else → next tab). Other handlers (j/kscroll, hint actions) applycount || 1locally. (#97)- Plugin:
src/workspace/global-key-handler.ts(dispatch— rawthis.countfor builtin,this.count || 1for obcommand repeat),src/workspace/global-defaults.ts(localcount || 1in scroll/hint handlers)
- Plugin:
-
Ngt(count + gt) ignored count in editor views — pressing2gtor3gtin an editor view always went to the next tab instead of the Nth tab. Root cause: the editor-modegtwas mapped toworkspace:next-tabviacreateCommandAction, which ignoresactionArgs.repeatentirely. The count-awaregotoTabaction was only mapped tog<C-t>. Fixed by replacing thegtmapping with a newgtActionthat usesactionArgs.repeatIsExplicitto distinguish “no count typed” (next tab) from “count N typed” (go to tab N). (#97)- Plugin:
src/workspace/navigation.ts(gtAction—repeatIsExplicitcheck,gotoNthTabfor explicit count,workspace:next-tabfor no count)
- Plugin:
-
gotoNthTabcounted sidebar leaves in tab numbering —Ngtandg<C-t>counted all workspace leaves (including sidebar panes) when determining the Nth tab.3gtcould navigate to a sidebar pane instead of the 3rd editor tab. Fixed by filtering leaves withleaf.getRoot() === app.workspace.rootSplitto only count main editor area leaves, matching the existing pattern insrc/lua/loader.ts. (#97)- Plugin:
src/workspace/global-defaults.ts(gotoNthTab—rootSplitfilter),src/workspace/navigation.ts(createGotoTabAction—rootSplitfilter)
- Plugin:
-
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) Inembeddable-editor.ts, thebuiltinVimOnclosure variable capturedisVimEnabled(app)which returnstruewhen 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’sregisterEditorExtension()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-constructionensureVimExtension()safety net that checks for vim presence viagetCM()and appends it viaStateEffect.appendConfigonly if absent. (2) Inmanager.ts,openOil()calledgetLeaf(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(removedbuiltinVimOnclosure, removed dead vim push frombuildLocalExtensions, addedensureVimExtension()withgetCMcheck +StateEffect.appendConfigfallback, replacedisVimEnabledimport withisBuiltinVimEnabled+getCM),src/oil/manager.ts(openOil— leaf priming withsetViewState({ type: 'markdown' })when no active MarkdownView)
- Plugin:
-
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()calledcache.loadDirectory()three times during a single refresh cycle, causing buffer entry IDs to become out of sync with the cache. Entry lookup by ID returnedundefined, soopenEntryAtCursor()silently aborted. Fixed by passing the expected buffer content from the initial render as a parameter todiscoverAndMergeHidden(), eliminating the redundantrenderDirectoryToBuffer()call that triggered the thirdcache.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— acceptsexpectedContentparameter, removed redundantrenderDirectoryToBuffercall),src/oil/oil-view.ts(callers pass rendered content)
- Plugin:
-
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()andrefreshContent()updatedthis.dirPathbut never signaled Obsidian to re-readgetDisplayText(). Fixed by addingnotifyHeaderChanged()which callsleaf.updateHeader()(Obsidian internal) after dirPath changes, insetDirectory(),refreshContent(), andsetState(). (#93)- Plugin:
src/oil/oil-view.ts(notifyHeaderChangedprivate method, called fromsetDirectory,refreshContent,setState)
- Plugin:
-
Hidden files toggle (
g.) has no effect — pressingg.in Oil to toggle hidden files did nothing. Root cause:this.settings.oilShowHiddenFiles ?? this.showHiddenused the nullish coalescing operator (??), butoilShowHiddenFilesis typed asboolean(defaultfalse), so??never fell through to the runtime togglethis.showHidden. Fixed by replacing the boolean field with ashowHiddenOverride: boolean | null(null = use setting) and agetEffectiveShowHidden()helper that prioritizes the override when set. (#93)- Plugin:
src/oil/manager.ts(showHiddenOverridefield,getEffectiveShowHidden()helper,toggleHidden()rewritten)
- Plugin:
-
<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()usedopenLinkText()which cannot replace a custom view type. Fixed by usingleaf.openFile()directly on the Oil leaf vianavigateWithJumpFile(), matching the pattern used bycloseOil(). (#93)- Plugin:
src/oil/manager.ts(openEntryAtCursorrewritten to useopenFileInLeaf, newopenFileInLeafprivate method),src/oil/keybindings.ts(oilOpenEntrydelegates tomanager.openEntryAtCursor())
- Plugin:
Added
- Oil
<C-t>open in new tab — new:oilopentabex 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)
- Plugin:
- Oil
<C-s>/<C-h>split open — new:oilopensvand:oilopenshex 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)
- Plugin:
- Oil
<C-c>close —<C-c>now maps to:oilclose, matching oil.nvim’s default close binding.qremains as an additional close key.- Plugin:
src/oil/keybindings.ts(mapping)
- Plugin:
- Oil
gxopen in default app — new:oilopenexternalex command mapped togx, matching oil.nvim’s default. Opens the file under cursor in the system’s default application viaapp.openWithDefaultApp().- Plugin:
src/oil/manager.ts(openEntryExternalAtCursor),src/oil/keybindings.ts(mapping + action)
- Plugin:
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),getEffectiveShowHiddenoverride logic (5 tests),renderDirectoryat 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),gxmethod registration (1 test), Obsidian reload for split cleanup (1 test) Modalclass added totest/unit/__mocks__/obsidian.tsto unblock unit tests importingmanager.ts
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Marked Oil non-editor context degradation as fixed; updated vim state per-editor note withensureVimExtension()safety netCONTRIBUTING.md: Updatedembeddable-editor.tsdescription (ensureVimExtension safety net) andmanager.tsdescription (leaf priming)AGENTS.md: Updated dual-vim architecture section with embedded editor vim injection and safety netdocs/features/oil-explorer.md: Added non-editor context opening noteKNOWN_LIMITATIONS.md: Added which-key popup timeout fix and gt/Ngt tab navigation fixesdocs/features/workspace-navigation.md: Added Ngt count support description and which-key timeout fix notedocs/reference/keybindings.md: Already hadNgtrow — no change neededCONTRIBUTING.md: Updatedglobal-key-handler.tsandglobal-defaults.tsdescriptionsKNOWN_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 keymapsdocs/features/ex-commands.md: Updated Oil ex commands table with new keymapsdocs/reference/keybindings.md: Updated Oil keybindings table with new keymapsCONTRIBUTING.md: Updated Oil keybindings descriptionREADME.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 Luavim.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()capturedpreviousFile(path only) but not the editor’s view mode. Fixed by capturingpreviousViewMode(theMarkdownViewstate: source mode, live preview, or reading mode) when opening Oil and restoring it vialeaf.openFile(file, { state: previousViewMode })on close. All 4 close paths (keybindingsq, ex commands:q/:wq, and Lua APIvim.ob.oil.close()) are unified into a singlecloseOil()method onOilManager. (#93)- Plugin:
src/oil/oil-view.ts(previousViewModefield,getState/setStateextended,getPreviousViewModegetter),src/oil/manager.ts(openOilcaptures mode viaMarkdownView.getState(), newcloseOil()shared method with mode restoration),src/oil/keybindings.ts(oilClosedelegates tomanager.closeOil()),src/workspace/commands.ts(closeOilViewdelegates tooilManager.closeOil()),src/main.ts(Lua APIoilClosedelegates tooilMgr.closeOil())
- Plugin:
- Cursor focus lost when switching back to Oil tab — after opening a file from Oil and then switching back to the Oil tab via
gTor 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 inonOpen()and never re-applied when switching back. Fixed by adding afocusEditor()method toOilViewand calling it fromOilKeybindingManager.onActiveLeafChange()when switching into an Oil view. (#93)- Plugin:
src/oil/oil-view.ts(focusEditor()public method),src/oil/keybindings.ts(onActiveLeafChangecallsview.focusEditor()when switching to Oil)
- Plugin:
: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 conditionif (!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(:Oilex command path resolution),src/ui/global-ex-command.ts(global ex command path resolution)
- Plugin:
- 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()andapp.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 viaapp.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 lackTFile/TFolderobjects in the Vault index. (#93)- Plugin:
src/oil/render.ts(exportedgetParentPath/isInConfigDir, newdiscoverHiddenEntries()function),src/oil/manager.ts(newdiscoverAndMergeHidden()method with race condition guard),src/oil/oil-view.ts(setEditorContent()method, async trigger inonOpen()andrefreshContent())
- Plugin:
- 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 fords"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 sequentiali += 2pairing) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(added “Symmetric surround quote matching” section)
- Fork:
- Snippet ex commands do not work after vimrc/Lua config reload —
:snippet <name>and:snippetsex commands silently stopped working after anyreloadFeatures()cycle (triggered by vimrc loading, Lua config loading, or settings changes). Root cause:registerSnippetCommands()was called only inonload(), butreloadFeatures()callsunregisterAll()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 separatepickerRegistrynot managed byVimRegistration. Fixed by addingregisterSnippetCommands()toreloadFeatures(), matching the pattern used by all other feature registrations. (#95)- Plugin:
src/main.ts(reloadFeatures— addedregisterSnippetCommandscall gated byenableSnippets)
- Plugin:
- 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 (" "), butonKeyPressLeaderOnly()compared against normalized keys ("<Space>"fromvim-keypressevents). The stored binding keys (" f") never matched the normalized drill-down prefix ("<Space>"). Similarly,addGroupLabel()stored the group label key in raw format, causinggetRelativeGroupLabels()lookups to miss. Fixed by normalizing bothlhsandprefixvianormalizeVimKey()at storage time inaddBinding()andaddGroupLabel(). (2) In grouped mode,buildNextKeyEntries()calledisSpecialKey()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— normalizelhsand leader before stripping;LeaderRegistry.addGroupLabel— normalizeprefixbefore storing;isSpecialKey— exempt<Space>from special key filtering)
- Plugin:
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:LeaderRegistrynormalization (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),clearBuiltinBindingswith 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::snippetcommand expands by name,:snippetsopens picker - 1 e2e test in
test/specs/settings-reload.e2e.ts: snippet ex commands survivereloadFeatures()(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.mdKNOWN_LIMITATIONS.md: Added surround doubled symmetric delimiter fixAGENTS.md: Updated fork test count (1882)DIFFERENCES.md(fork): Added “Symmetric surround quote matching” sectiondocs/features/surround.md: Added doubled delimiter behavior noteKNOWN_LIMITATIONS.md: Added hidden files view-only limitation to Oil section; marked Reading Mode freeze, focus loss, and:Oil .path resolution as fixedCONTRIBUTING.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 limitationdocs/features/ex-commands.md: Updated:Oilargument descriptiondocs/reference/keybindings.md: Updated:Oildescription with.//path supportKNOWN_LIMITATIONS.md: Marked ex command snippet expansion as fixed; added which-key EasyMotion double-leader fix to which-key overlay sectiondocs/features/snippets.md: Updated ex command trigger description noting reload survivaldocs/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 —
.afteri<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_surroundInsertCharand_surroundInsertNewlineonlastInsertModeChanges. During replay,replaySurroundAwareInsert(insiderepeatLastEdit) strips the delimiter entry fromchanges[0], insertspair.open, replays typed text viarepeatInsert, then insertspair.close. Wrapped incm.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,replaySurroundAwareInsertinrepeatLastEdit,onCursorActivity),~/Repos/codemirror-vim/src/types.ts(_surroundInsertChar,_surroundInsertNewlinefields)
- Fork:
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.mdKNOWN_LIMITATIONS.md: Marked insert-mode surround dot-repeat as fixed; separated macro recording limitation into own sectionREADME.md: Updated surround feature description with insert-mode dot-repeatAGENTS.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 detailsdocs/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’srepeatLastEditre-reads the register at replay time. Follows yanky.nvim’supdate_register_on_cyclesemantics. System clipboard registers ("+/"*) are excluded.- Plugin:
src/vim/yank-ring.ts(originalPasteRegistertracking,getPasteRegisterName(), register write incancel(),setVim())
- Plugin:
undefineExfork API — the codemirror-vim fork now exposesVim.undefineEx(name)to remove ex commands registered viadefineEx. Cleans both theexCommandsfunction map andcommandMap_prefix lookup. Returnstrueif the command existed,falseotherwise.- Fork:
~/Repos/codemirror-vim/src/vim.js(undefineExonvimApi)
- Fork:
- Exmap unregistration on vimrc soft-reload — removing an
exmapdefinition from the vimrc file now unregisters the old handler on save. Exmap names are tracked per vimrc load invimrcExmapNamesand cleaned viaundefineExbefore re-applying on soft-reload. Plugin-defined and fork built-in ex commands are unaffected.- Plugin:
src/main.ts(vimrcExmapNamesSet, cleanup insoftReloadVimrc),src/vimrc/loader.ts(exmapNamesinApplyResultandVimrcLoadResult),src/types/vim-api.d.ts(undefineExtype,setTexton registers,lastEditInputStateonVimState)
- Plugin:
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:vimrcExmapNamesfield exists,undefineExavailable on Vim API, returns false for nonexistent, defineEx + undefineEx round-trip with built-in survival
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Removed:earlier/:laterand:signfrom 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 fixedREADME.md: Updated yank-ring feature description with dot-repeatAGENTS.md: Updated codemirror-vim fork description withundefineExAPI and test countdocs/features/quality-of-life.md: Updated yank-ring section with dot-repeat behaviordocs/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 viahotkeys.json,VimInfoModaldisplay),src/settings.ts(conflictNoticeDismissedVersionsetting, button in both declarative and imperative settings paths),src/main.ts(detection ononLayoutReady)
- Plugin:
- Per-view
CursorMoved/TextYankPost/CursorHold/CmdlineEnter/CmdlineLeaveautocmd events — extends the per-view autocmd pattern (already implemented for mode events viaAutocmdModeWatcher) to 5 additional events.CursorMovedfires independently per view with position-change detection (only fires when cursor actually moved).TextYankPostfires from any view including popovers.CursorHoldfires per-view with configurable delay. Built-in vim mode retains active-leaf-only behavior.- Plugin:
src/vim/autocmd-event-watcher.ts(new —AutocmdEventWatcherViewPlugin),src/lua/autocmd.ts(useEventViewPluginflag, per-view handler methods, gated legacy bindings),src/main.ts(extension registration, callback wiring, hold delay sync)
- Plugin:
- 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 inonCommandDone,prevAnchor/prevDocLength/prevSelectionLength/prevVisualLine/prevVisualBlocktracking)
- Plugin:
- Console warning for unknown
setoptions — unknownsetoptions in vimrc now produce aconsole.warnon 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_OPTIONSset,warnedSetOptionsdeduplication,clearSetOptionWarnings())
- Plugin:
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 witht/Ttill motions.- Plugin:
src/flash/char-mode.ts(count prefix check before label overlay)
- Plugin:
- Flash dot-repeat clarified — dot-repeat after
df{char}{label}already works correctly. The fork stores the resolved position via_asyncMotionTargetandrepeatLastEditreplays 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
Tabin insert mode inside an embedded table cell editor froze the editor. The cursor disappeared, vim mode got stuck in Insert mode, andEscapestopped working. Root cause:exitCellEdit()scheduled a 50msrefreshAfterOp()timer that was non-cancellable and had no state guard. WhenTabcalledexitCellEdit()→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-editstate withtable-navhandlers). Fixed with four layers of defense: (1)refreshAfterOp()now stores and deduplicates the timer ID in arefreshTimermember, cancelled inexitTable(),enterCellEdit(), anddestroy(). (2)doRefreshAfterOp()guards against firing incell-editorinactivestate. (3)exitCellEdit()accepts{ skipRefresh: true }— the Tab handler skips bothsetActiveEditTableRange(null)andrefreshAfterOp()to prevent widget DOM rebuilds during cell-to-cell transitions. (4)enterCellEdit()cancels any pending refresh timer as belt-and-suspenders protection. Additionally,Tabat the last cell of the last row (orShift-Tabat 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(refreshTimermember,refreshAfterOptimer storage/dedup,doRefreshAfterOpstate guard,exitCellEditskipRefreshparam,handleCellEditKeyrewrite with widget re-query and boundary handling,enterCellEdittimer cancel andpendingDcleanup)
- Plugin:
- 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::selectionhighlights. Fixed by adding aCSSStyleSheetondocument.adoptedStyleSheetsthat forces::selectionvisibility in.cm-vimVisual:not(.cm-vimVisualLine)scoped to.vim-table-cell-editor. Linewise visual mode (V) already worked via the fork’s focus-independentlinewiseVisualHighlightViewPlugin. (#19)- Plugin:
src/vim/table-cell-editor.ts(visualSelectionSheetviaadoptedStyleSheets)
- Plugin:
- 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 whenundoFileis enabled. Persisted data on disk is not deleted — reopening a file restores from persistence or starts fresh.- Plugin:
src/main.ts(undo tree eviction inactive-leaf-changehandler)
- Plugin:
- Undo tree stale-tree notification — when
undoFileis enabled and a file was modified outside Obsidian between sessions, an Obsidian Notice is now shown when the persisted undo tree’sdocLengthdoesn’t match the current file size. Detection fires at most once per file per session. Legacy persisted trees withoutdocLengthgracefully skip the check.- Plugin:
src/main.ts(activateUndoTreeForFile—docLengthcomparison + Notice),src/vim/undo-tree.ts(docLengthfield onSerializedUndoTree)
- Plugin:
vim.v.insertmodenow 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 viagetInsertModeChar(). Autocmd callbacks default to''(no adapter context available).- Plugin:
src/lua/api.ts(getInsertModeCharhelper,insertmodeadded to 5setVimVContextcallsites)
- Plugin:
Tests
- 2 unit tests in
test/unit/undo-tree.test.ts:docLengthround-trip preservation, legacy data withoutdocLengthgraceful deserialization - 9 unit tests in
test/unit/lua/vim-v.test.ts:insertmode'r'/'v'context values, 7getInsertModeChartests (null, normal, insert, replace, virtual replace, priority, missing state) - 2 unit tests in
test/unit/textarea-vim.test.ts:clearSetOptionWarningsexport and idempotency - 5 e2e tests in
test/specs/flash-char-mode.e2e.ts:3fadirect jump,5faclamp to last match,2fadirect jump,d3faoperator-pending,1fasingle 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.mdKNOWN_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 entriesdocs/features/flash.md: Added count prefix behavior and dot-repeat notedocs/features/undo-tree.md: Updated memory management and stale-tree notificationdocs/features/tables.md: Updated visual mode highlighting fix in cell editorsdocs/features/quality-of-life.md: Updated yank-ring with visual-mode paste cyclingdocs/configuration/lua-config.md: Updatedvim.v.insertmodefrom deferred to active, updated per-view autocmd event listdocs/configuration/vimrc.md: Added unknown set option warning behavior, updated exmap soft-reloaddocs/configuration/settings.md: Added hotkey conflict detection button to workspace navigation settingsdocs/getting-started/recommended-setup.md: Added hotkey conflict wizard noteREADME.md: Updated flash motions, workspace navigation, and Lua configuration feature descriptionsAGENTS.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
whichKeyModewas 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()) queriedvim.getCompletions()from the CM vim engine, which returns the entiredefaultKeymaparray — including built-in defaults, plugin-internal keymaps, and user-defined keymaps — instead of using only theleaderBindingsregistry (which contains only user-visible leader keymaps). The “Leader key only” mode (showLeaderBindings()) was unaffected because it already usedleaderBindingsdirectly. Fixed by adding anisLeaderScopebranch inshowCompletions()that mirrorsshowLeaderBindings()— building entries fromthis.leaderBindingsfiltered by the current prefix, with correct label/icon/color resolution and leader-style title formatting. Non-leader completions (g,z,d, etc.) continue usingvim.getCompletions()as before. (#91)- Plugin:
src/ui/which-key.ts(showCompletions—isLeaderScopebranch, deferredgetCompletionsto non-leaderelsebranch)
- Plugin:
- Several
vim.optand vimrcsetoptions produce “unknown vim.opt option” warning — 12 plugin settings were documented but never registered in the Luavim.optproxy (KNOWN_SET_OPTIONS) or the vimrc:setpathway (vim.defineOption). Setting them viavim.opt.yankring = trueorset yankringin 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_OPTIONSandvim.defineOption:yankring,yankhighlightmode,yankhighlightduration,undotree,undofile,undotreemaxnodes,foldawarenavigation,foldpersistence,harpoon,dial - Added to
KNOWN_SET_OPTIONSonly (already hadvim.defineOption):jumplist,jumplistsize - Plugin:
src/vimrc/loader.ts(12 newKNOWN_SET_OPTIONSentries +setJumpListEnabled/setJumpListSizeimports),src/vim/options.ts(10 newvim.defineOptioncalls + 2 new exported setters)
- Added to both
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.mdKNOWN_LIMITATIONS.md: Added which-key inflated group count fix; added vim.opt/vimrc option parity fixdocs/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
propertiesFoldObserverinfold-sync.tswatched.metadata-containerfor anyclassattribute mutation and unconditionally dispatchedEditorView.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 addingattributeOldValue: trueto theMutationObserverconfig and comparing the old vs newis-collapsedclass presence — the observer now only firesscrollCursorIntoView()when the fold state actually changes. No-op mutations (identical class string) and non-fold mutations (any class other thanis-collapsed) are ignored. (#89)- Plugin:
src/vim/fold-sync.ts(propertiesFoldObserver—is-collapsedfilter,attributeOldValue: true)
- Plugin:
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.mdKNOWN_LIMITATIONS.md: Added properties fold observer scroll fixCONTRIBUTING.md: Updatedfold-sync.tsdescription withis-collapsedfilterdocs/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-linkspans (not.cm-underline), and markdown links render as.cm-link/.cm-urlspans. These were not inTARGET_SELECTOR, so no hint labels appeared. In Source mode, wikilinks always render as.cm-hmd-internal-linkand were similarly missed. Fixed by adding.cm-hmd-internal-link,.cm-link, and.cm-urltoOBSIDIAN_SELECTORSand extendingclassifyTarget()to resolve links from these elements via the existingresolveCmUnderlineHref()pipeline. Deduplication filters prevent multiple hints per link: aliased wikilink sub-spans, nested.cm-underlineinside.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, extendedclassifyTarget, deduplication filters increateHintAction)
- Plugin:
- Hint mode link resolution fails in Obsidian runtime —
getEditorViewFromElement()used the DOM.cmView.viewproperty 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 returnedhref: undefined, causing hint labels to appear but do nothing when activated. Fixed by falling back to theMarkdownView.editor.cmpath (the same accessor used by the rest of the codebase viagetEditorView()insrc/util/editor.ts). (#85)- Plugin:
src/ui/hint-mode.ts(getEditorViewFromElementfallback viaapp.workspace.getActiveViewOfType(MarkdownView))
- Plugin:
- 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 theisInternalLinkcheck excluded URLs starting withhttp://orhttps://. The generic click handler dispatches pointer/click events on<span>elements, which have no click handler and produce no effect. Fixed by adding an explicitwindow.open(linkHref)path for external URLs. (#85)- Plugin:
src/ui/hint-mode.ts(hintActivateexternal URL branch)
- Plugin:
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,yfyank on wikilink,Fopen-in-new-tab on wikilink, embed wikilink hint visibility - Mode-switching helpers (
ensureLivePreview,ensureSourceMode,isLivePreview,isSourceMode) extracted totest/helpers.ts - Spike test
test/specs/spikes/spike-hint-wikilink-issue85.e2e.ts: 17 diagnostic tests probing DOM element discovery,posAtDOMmapping accuracy,findLinkAtCursorresolution, and end-to-end hint activation across Live Preview, Source mode, and Reading view
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Updated hint mode target classification with.cm-hmd-internal-link,.cm-link,.cm-urlselectors, deduplication filter descriptions, EditorView fallback, and external URL handlingCONTRIBUTING.md: Updatedhint-mode.tsdescription with cursor-on-line and Source mode link resolution, EditorView MarkdownView fallback, external URL handlingdocs/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, andModeChangedautocmd 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 theAutocmdManagerbound to a single adapter viaonActiveLeafChange(). Non-leaf editors (popovers, canvas cards) never triggeredactive-leaf-change, so autocmd callbacks for mode events never executed in these contexts. Fixed by addingAutocmdModeWatcher, a CM6ViewPluginthat hooksvim-mode-changeper-EditorView and fires mode events throughAutocmdManager.fire(). The ViewPlugin is registered viaregisterEditorExtension()and automatically applies to all editors. The single-adapter mode-change binding inbindAdapter()andactivate()is gated by auseViewPluginflag — 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 —AutocmdModeWatcherViewPlugin,setAutocmdModeCallbacks/clearAutocmdModeCallbacks),src/lua/autocmd.ts(useViewPluginflag,setUseViewPlugin(),handleModeChangeFromView(), guardedonModeChangeinactivate()andbindAdapter()),src/main.ts(extension registration, callback wiring inloadLuaConfigInternal, cleanup inonunload)
- Plugin:
- 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-linkspans (not.cm-underline), and markdown links render as.cm-link/.cm-urlspans. These were not inTARGET_SELECTOR, so no hint labels appeared. In Source mode, wikilinks always render as.cm-hmd-internal-linkand were similarly missed. Fixed by adding.cm-hmd-internal-link,.cm-link, and.cm-urltoOBSIDIAN_SELECTORSand extendingclassifyTarget()to resolve links from these elements via the existingresolveCmUnderlineHref()pipeline. Deduplication filters prevent multiple hints per link: aliased wikilink sub-spans, nested.cm-underlineinside.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, extendedclassifyTarget, deduplication filters increateHintAction)
- Plugin:
Tests
- 3 new unit tests in
test/unit/lua/autocmd.test.ts:handleModeChangeFromViewfires events,bindAdapterskips mode-change whenuseViewPluginis true,activateskipsonModeChangewhenuseViewPluginis 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 viaVim.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,yfyank on wikilink,Fopen-in-new-tab on wikilink, embed wikilink hint visibility - Mode-switching helpers (
ensureLivePreview,ensureSourceMode,isLivePreview,isSourceMode) extracted totest/helpers.ts - Spike test
test/specs/spikes/spike-hint-wikilink-issue85.e2e.ts: 17 diagnostic tests probing DOM element discovery,posAtDOMmapping accuracy,findLinkAtCursorresolution, and end-to-end hint activation across Live Preview, Source mode, and Reading view
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Added per-view mode events section documenting which events fire per-view, which remain active-leaf-only,getModeState()semantics, andvim.obsidian.mode()behavior; updated hint mode target classification with.cm-hmd-internal-link,.cm-link,.cm-urlselectors and deduplication filter descriptionsdocs/configuration/lua-config.md: Added per-view callout to autocommands section, markedInsertEnter/InsertLeave/ModeChangedas “(per-view)” in events tabledocs/features/hint-mode.md: Updated internal link handling section with Source mode support and cursor-on-line behaviorCONTRIBUTING.md: Addedautocmd-mode-watcher.tsto codebase structure; updatedhint-mode.tsdescription with cursor-on-line and Source mode link resolutionAGENTS.md: Updated Lua API description noting per-view autocmd mode eventsREADME.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) andg$(multi-key) caused the cursor to teleport instead of animating. TheresolveVimMode()method in the animated cursor controller usedvim.status(the chord display string) to detect operator-pending mode. Sincevim.statusis 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 calledsnap(), bypassing the animation entirely. Fixed by removingvim.statusfrom the operator-pending detection — onlyinputState.operator(set when an actual operator liked/c/yis registered) now gates the operator-pending mode. (#86)- Plugin:
src/vim/animated-cursor/controller.ts(resolveVimMode— removedvim.statuscheck)
- Plugin:
- 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-underlinespans rendered by Live Preview are<span>elements withouthrefordata-hrefattributes —classifyTargetcorrectly identified them as links but extractedhref: undefined, causinghintActivateto fall through to the generic click handler (which does nothing useful on CM6 spans). Fixed by addingresolveCmUnderlineHref()which uses the CM6EditorView.posAtDOM()API to convert the DOM element to a document offset, then calls the existingfindLinkAtCursor()regex fromgoto-definition.tsto 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 properhref/data-hrefattributes). (#85)- Plugin:
src/ui/hint-mode.ts(getEditorViewFromElement,resolveCmUnderlineHref, updatedclassifyTargetlink branch)
- Plugin:
- 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
Escthenireset the IME to the original input method instead of preserving the manually chosen one. Thesave()method inImSwitchercached the stalelastKnownImvalue (set by the plugin’s lastset()call) before querying the OS for the actual current IME. The async OS query updatedlastKnownImbut never wrote back tosavedImByLeaf, sorestore()always read the stale value. Fixed by makingsave()async — it now queries the OS for the real IME state first, then caches the result in bothlastKnownImandsavedImByLeaf.onInsertLeave()awaits the save before switching to the normal-mode default IME. Falls back tolastKnownImwhen the OS query fails (e.g., binary timeout). (#83)- Plugin:
src/im/im-switcher.ts(save()async with OS query,onInsertLeave()awaits save,debouncedSwitch/pendingSwitchaccept async callbacks),src/lua/api.ts(imSavetype updated),src/lua/loader.ts(fire-and-forget async save),src/lua/obsidian-api.ts(void floating promise)
- Plugin:
Tests
- 10 e2e tests in
test/specs/hint-mode-links.e2e.ts: wikilink/markdown-link/bare-URL href resolution from.cm-underlinespans, 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 (mockexecuteImGetreturn value instead of manually settinglastKnownIm), async settle viavi.advanceTimersByTimeAsync(0), new test for fallback when OS query returns null
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Added animated cursor multi-key motion fix; added hint mode link navigation fix to hint mode actions sectionCONTRIBUTING.md: Updatedhint-mode.tsdescription with link resolution viaposAtDOMdocs/features/hint-mode.md: Updated internal link handling section with Live Preview resolution detailsKNOWN_LIMITATIONS.md: Updated input method switching section with manual IME switch fixCONTRIBUTING.md: Updatedim-switcher.tsdescriptionAGENTS.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. ThewaitForKey()handler resolved on theShiftkeydown event (before the actual character key arrived), causing the motion to silently abort. Fixed by adding a modifier-key guard matching the existing pattern inwaitForLabel()—e.key.length !== 1keys are now suppressed and ignored, keeping the handler alive for the real character. (#84)- Plugin:
src/easymotion/keypress.ts(waitForKeymodifier-key guard)
- Plugin:
Tests
- 6 unit tests in
test/unit/easymotion-keypress.test.ts:waitForKeyresolves 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.mdKNOWN_LIMITATIONS.md: Added capital letter fix to EasyMotion operator-pending sectionCONTRIBUTING.md: Updatedkeypress.tsdescription with modifier-key guarddocs/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 viaregisterEditorExtension():CompositionTrackertrackscompositionstart/compositionendper-EditorView, andImModeWatcherbindsadapter.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, addedcleanupView()),src/main.ts(registered extensions, removed autocmd-based IM registrations)
- Plugin:
Tests
- 15 unit tests in
test/unit/composition-tracker.test.ts: per-view composing state, multi-tracker isolation, destroy cleanup,onAllCompositionsEndcallback 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.mdKNOWN_LIMITATIONS.md: Updated input method switching section with multi-view fixREADME.md: Updated input method switching feature description with multi-view supportCONTRIBUTING.md: Addedcomposition-tracker.tsandim-mode-watcher.tsto codebase structureAGENTS.md: Updated Lua API description noting per-view IM switching
[0.79.0] - 2026-07-22
Added
vim.vnamespace — Neovim-compatible predefined variables — read-only metatable proxy exposingvim.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), andvim.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 fromactionArgsbefore each keymap callback invocation and cleared after.vim.v.eventis populated during autocmd dispatch with the event data table.vim.v.hlsearchqueries the fork’s search overlay state viagetSearchState(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,getHlSearchcallbacks),src/types/vim-api.d.ts(ActionArgsextended,feedKeysandgetOverlayadded toVimApi),src/lua/engine.ts(EXPR_INSTRUCTION_LIMIT)
- Plugin:
{ expr = true }keymap support —vim.keymap.setnow accepts{ expr = true }for function callbacks. The callback must return a string that is fed as keystrokes via the fork’s newfeedKeysAPI. 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(feedKeysmethod on VimApi — delegates todoKeyToKeywith noremap flag and recursion protection) - Plugin:
src/lua/api.ts(expr callback path withlua_pcall(state, 0, 1, 0), return value capture,feedKeysinvocation)
- Fork:
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.countandvim.v.count1with and without typed counts,vim.v.eventpopulated during InsertEnter/InsertLeave autocmds,vim.v.hlsearchreturns 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.mdKNOWN_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: Addedvim.vsection with all variable tables, expr mapping examples, and updatedvim.keymap.setoptions table withexprdocumentationREADME.md: Updated Lua configuration feature description withvim.vand expr mappingsCONTRIBUTING.md: Updatedapi.tsdescription with vim.v namespace and expr mapping supportAGENTS.md: Updated codemirror-vim fork description withfeedKeysAPI, updated Lua API list withvim.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
foldStaterequires an exact{from, to}match to remove a fold; a mismatched range is silently ignored. Fixed by addingunfoldNormalizerExtenderinfold-sync.ts— atransactionExtenderthat detects mismatchedunfoldEffectranges 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)
- Plugin:
- 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 toexitInsertMode. Fixes: (1) cursor now lands on the last typed character afterEsc(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). ThemaybeResetmechanism clears delimiter text from the insert-mode change stream solastInsertModeChanges.changescontains 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,surroundInsertNewlinerefactored;exitInsertModedeferred-close block removed),~/Repos/codemirror-vim/src/types.ts(surroundInsertCloseproperty removed)
- Fork:
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.mdKNOWN_LIMITATIONS.md: Updated fold gutter unfold fix with unfold normalizer extender descriptionCONTRIBUTING.md: Updatedfold-sync.tsdescription with unfold normalizerdocs/features/workspace-navigation.md: Added unfold normalizer note to Folds sectionREADME.md: Updated surround feature description with insert-mode cursor fixAGENTS.md: Updated codemirror-vim fork description noting insert-mode surround refactordocs/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
requestAnimationFrameloop — 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 500mssetIntervalheartbeat that detects a stalled loop and re-wakes it — unlike rAF,setIntervalis not suppressed by Chromium’s occlusion tracker. (3) When the browser tab/window is hidden and restored, rAF may not resume. Added avisibilitychangelistener that re-wakes the loop when the page regains visibility. (4) Windows displays at 125%/150% scaling produce fractionaldevicePixelRatiovalues (1.25/1.5). Canvas backing-store dimensions are now rounded withMath.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 inloop(), heartbeatsetInterval,visibilitychangelistener, DPR rounding)
- Plugin:
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Added Windows resilience details to animated cursor sectionCONTRIBUTING.md: Updatedmanager.tsdescription with resilience mechanismsdocs/features/animated-cursor.md: Added Windows resilience sectionREADME.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), andisCursorSuppressedForView(view)to the codemirror-vim fork. Per-view overrides take precedence over the globalsetCursorSuppressedstate, 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’sBlockCursorPluginis destroyed.- Fork:
~/Repos/codemirror-vim/src/block-cursor.ts(per-view state map, API implementation, cleanup indestroy)
- Fork:
- 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 togglesmoothcursorenables/disables the feature withreloadFeatures(). Sub-options (smoothcursorglide,smoothcursorsmoothness,smoothcursorsmear,smoothcursorstiffness,smoothcursortrailstiffness,smoothcursordamping,smoothcursormaxlength) hot-reload without restart. All useSideEffectOptpattern syncing bothsettings[key]and module-level config. Short aliases:sc,scg,scs,scm,scst,scts,scd,scml. (#78)- Plugin:
src/vimrc/loader.ts(16SideEffectOptentries),src/settings.ts(animatedCursoradded toRELOAD_KEYS)
- Plugin:
- Animated cursor in oil explorer — animated cursor now renders in the oil file explorer. Single shared canvas architecture: one canvas on
.app-containerowned byAnimatedCursorManager, shared by all controllers. Reduces memory from O(N × viewport) to O(1 × viewport). Each controller clips drawing to its own editor bounds viactx.clip().MAX_CONTROLLERSraised 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 oncanvas.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(injectscreateAnimatedCursorExtension()when enabled)
- Plugin:
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
unfoldEffectwas 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 fromfoldedRanges().between()and passing the full{ from, to }range tounfoldEffect. (#80)- Plugin:
src/vim/fold-column.ts(click handler),src/vim/statuscolumn.ts(handleFoldClick)
- Plugin:
Documentation
CHANGELOG.mdKNOWN_LIMITATIONS.md: Added fold gutter unfold fix to folding sectionREADME.md: Added vimrc/Lua configuration to animated cursor feature descriptionCONTRIBUTING.md: Updatedmanager.tsandconfig.tsdescriptions for Phase 3 architectureAGENTS.md: Updated codemirror-vim fork description with per-view cursor suppression APIdocs/features/animated-cursor.md: Added vimrc/Lua configuration section and oil explorer support sectiondocs/configuration/vimrc.md: Addedsmoothcursor,smoothcursorsmoothness,smoothcursorsmearto boolean options; addedsmoothcursorsmoothness,smoothcursorstiffness,smoothcursortrailstiffness,smoothcursordamping,smoothcursormaxlengthto number optionsdocs/configuration/lua-config.md: Added all 8smoothcursor*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
ViewPluginupdate 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(callssetCursorSuppressedForViewandpauseAnimatedCursorForView)
- Plugin:
-
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(callssetCursorSuppressedForView(view, false))
- Plugin:
-
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(callssetCursorSuppressedForView(view, false))
- Plugin:
-
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 checkedvim.status(set for prompt-based pending like surround) but notvim.inputState.operator(set for standard operators liked/c/y). (2) Operator-pending is a transient state that doesn’t trigger CM6 transactions, so the ViewPlugin’supdate()never fired. Fixed by checkinginputState.operatorinresolveVimMode()and polling the cursor shape every rAF frame intick()to detect changes that bypass CM6’s transaction system. (#78)- Plugin:
src/vim/animated-cursor/controller.ts(resolveVimModechecksinputState.operator, per-frame shape polling intick)
- Plugin:
Documentation
CHANGELOG.mdKNOWN_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)
- Plugin:
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: 0insidescrollDOM). After scrolling, cursor coordinates pointed to positions below the canvas bounds. Fixed by moving the canvas to.app-containerwithposition: fixedand using raw viewport-relative coordinates fromcoordsAtPos()directly — matching cursor-smith’s architecture. The canvas is clipped to the editor pane rect viactx.clip()each frame. (#78)- Plugin:
src/vim/animated-cursor/controller.ts(viewport-fixed canvas, removed scroll offset math),styles.css(fixed positioning)
- Plugin:
- 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’sselection.main.headpoints past the last selected character. The fork’sBlockCursorPlugin.measureCursor()decrementsheadin this case, but the animated cursor used the raw value. Fixed by applying the same head adjustment: whenanchor < headand the character at head isn’t\n, decrement position by 1. (#78)- Plugin:
src/vim/animated-cursor/controller.ts(head position adjustment inrefreshTarget)
- Plugin:
Documentation
CHANGELOG.mdKNOWN_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'ingetSettingDefinitions(). On pre-1.13, a button tab bar at the top of the settings panel switches between pages. Thedisplay()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)
- Plugin:
- 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)
- Plugin:
- 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: trueon 33 toggle controls for framework-managed defaults- Group-level search filter on Jump navigation group (15+ settings)
- Replaced 6 conditional spreads (
...(condition ? [...] : [])) withvisiblepredicates for cleaner reactivity viarefreshDomState() - Inline
validateon 8 numeric/path controls (range checks, path format validation) - Snippet directory changed from text input to
type: 'folder'vault folder picker - 38 conditional
visiblepredicates 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)
- Page descriptions (
- 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 toggleonChangehandlers 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 —syncVisibilityClasshelper + gate divs in render methods),styles.css(18-selector conditional visibility rule block)
- Plugin:
Documentation
CHANGELOG.mdAGENTS.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 namesKNOWN_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 imperativedisplay()method with matching toggle/slider controls, disabled-state gating, andreloadFeatures()on master toggle change.- Plugin:
src/settings.ts(post-1.13display()method)
- Plugin:
- 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
- Plugin:
- ESLint warnings in animated cursor module — resolved 8 lint issues: moved canvas inline styles to CSS class (
obsidianmd/no-static-styles-assignment), replacedinstanceofwith.instanceOf()(obsidianmd/prefer-instanceof), removed unnecessary type assertion (@typescript-eslint/no-unnecessary-type-assertion), replacedrequestAnimationFramewithwindow.requestAnimationFrame(obsidianmd/prefer-window-timers), replaceddocument.createElementwith Obsidian’screateElhelper (obsidianmd/prefer-create-el).- Plugin:
src/vim/animated-cursor/controller.ts,src/vim/animated-cursor/manager.ts,styles.css
- Plugin:
[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 viasetCursorSuppressed(). 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(setCursorSuppressedAPI),~/Repos/codemirror-vim/src/index.ts(re-export)
- Plugin:
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.mdKNOWN_LIMITATIONS.md: Added animated cursor section with limitations and nice-to-have future itemsREADME.md: Added animated cursor to features listCONTRIBUTING.md: Addedanimated-cursor/module to codebase structureAGENTS.md: Updated codemirror-vim fork description withsetCursorSuppressedAPI; added animated cursor to page ownership table~/Repos/codemirror-vim/DIFFERENCES.md: AddedsetCursorSuppressedAPI section
[0.72.0] - 2026-07-21
Added
labelmatchfontsizesetting — 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 labelmatchfontsizein vimrc, orvim.opt.labelmatchfontsize = truein Lua. (#75)- Plugin:
src/settings.ts,src/vim/options.ts,src/vimrc/loader.ts,src/easymotion/overlay.ts(per-targetlabelMetrics),src/easymotion/register.ts,src/flash/register.ts,src/flash/char-mode.ts,src/flash/jump-mode.ts,src/flash/search-mode.ts
- Plugin:
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’sfindPosVadapter 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’seditorLivePreviewFieldstate field. A newsetLivePreviewField()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,_livePreviewFieldgate infindPosV),~/Repos/codemirror-vim/src/index.ts(re-export) - Plugin:
src/vim/bundled-vim.ts(passeseditorLivePreviewFieldto fork),src/types/codemirror-vim.d.ts(type declaration)
- Fork:
- EasyMotion line motions targeting hidden formatting in Live Preview —
<leader><leader>j/<leader><leader>kline 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 addingskipHiddenPrefix()tofindLineTargets(), which scans forward from the raw-text first-non-blank character usingcoordsAtPos()to find the first character that occupies visible space. (#79)- Plugin:
src/easymotion/targets.ts(skipHiddenPrefix,findLineTargets)
- Plugin:
Documentation
CHANGELOG.mdKNOWN_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 fixAGENTS.md: Updated codemirror-vim fork description withsetLivePreviewFieldAPICONTRIBUTING.md: Updatedbundled-vim.tsdescription with live-preview field wiring~/Repos/codemirror-vim/DIFFERENCES.md: AddedsetLivePreviewFieldAPI section; updated properties navigation section with live-preview gatingdocs/configuration/settings.md: Addedlabelmatchfontsizeto Jump navigation settingsdocs/configuration/vimrc.md: Addedlabelmatchfontsize/lmfsto boolean optionsdocs/configuration/lua-config.md: Addedlabelmatchfontsizeto vim.opt tabledocs/features/flash.md: Addedlabelmatchfontsizeto configuration tabledocs/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 tok/j. Gated byenableYankRingsetting (default: on). Usesvim-keypressevent detection andaddToHistory.of(false)for single-undo-group cycling.- Plugin:
src/vim/yank-ring.ts(new),src/settings.ts(enableYankRing),src/main.ts(lifecycle integration)
- Plugin:
- Indentation text object (
ii/ai) —iiselects contiguous lines with same-or-greater indentation.aiadds the parent line above and trailing blank lines. Zero-indentation and blank lines return no match. Column-aware tab handling via CM6state.tabSize. Gated by existingenableTextObjectssetting.- Plugin:
src/text-objects/indentation.ts(new),src/text-objects/register.ts
- Plugin:
grblockwise visual mode —<C-V>block selection +grnow 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)
- Plugin:
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-matchhighlight boxes used a fixedwidth: 8px; height: 16pxregardless of actual character dimensions, breaking with proportional fonts, different font sizes, and CJK characters. Now dynamically measured viacoordsAtPos()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 = truebehavior. 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. NowshowOverlayrenders 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 usesupdateLabels()to narrow labels while preserving all match highlights. (#75)- Plugin:
src/easymotion/overlay.ts(extractedmeasureTarget+measureLabelAnchor+renderHighlightSpansshared helpers, addedrenderHighlightstoshowOverlay, label positioning at end-of-match),src/easymotion/types.ts(matchLength?: numberonTarget),src/easymotion/targets.ts(matchLengthinfindSubstringTargets),src/flash/search-mode.ts(matchLengthinfindSearchMatchTargets),src/flash/jump-mode.ts(widenedcurrentOverlaytype toOverlayHandle, label narrowing viaupdateLabels),styles.css(CSS custom properties for.vim-motions-flash-matchdimensions)
- Plugin:
- 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 sharedwaitForFlashLabel()intosrc/flash/label-input.tsfor reuse bychar-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 sharedwaitForFlashLabel)
- Plugin:
- Flash jump
sconflicting with surroundcs/ys/dsin operator-pending mode — when flash jump mode was enabled withsas 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 anoperatorPendingaction (e.g., surround’ss<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 inmatchCommand(), timer inhandleKeyNonInsertMode(), cleanup inclearInputState()+ teardown,operatorshadowtimeoutoption) - Fork:
~/Repos/codemirror-vim/src/types.ts(_shadowTimeronvimState) - Plugin:
src/settings.ts(operatorshadowtimeoutsetting + Settings UI),src/vimrc/loader.ts(operatorshadowtimeout/ostinKNOWN_SET_OPTIONS)
- Fork:
Tests
- 5 unit tests in
test/unit/flash-targets.test.ts:findSubstringTargetssetsmatchLength(5-char, 1-char, 2-char patterns), empty pattern returns empty,findCharTargetsdoes not setmatchLength - 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 flashsenabled) - 10 tests in
~/Repos/codemirror-vim/test/vim_test.js: shadow resolver (cs,ds,ysiwsurround wins,dd/ccregression,dw/cwno-activation, Escape clears timer,g~regression,ost=0disables) - 6 e2e tests for
grblockwise intest/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.mdREADME.mdCONTRIBUTING.md: Updated overlay.ts description; addedlabel-input.tsto codebase structureAGENTS.md: Updated codemirror-vim fork description with operator-prefix shadow resolver and test countKNOWN_LIMITATIONS.md: Updated flash motions section with highlight sizing and label positioning fixes; added operator-prefix key dispatch section (Implemented); updatedskey / surround conflict bullet to reference resolverdocs/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 documentationdocs/features/easymotion.md: Added note about label positioning changedocs/features/text-objects.mddocs/features/quality-of-life.mddocs/reference/keybindings.mddocs/configuration/settings.md: Addedoperatorshadowtimeoutto Vim engine settings groupdocs/configuration/vimrc.md: Addedoperatorshadowtimeout/ostto numeric options tabledocs/configuration/lua-config.md: Addedvim.opt.operatorshadowtimeoutentry~/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/Nfand:latertime/count/save-point navigation,:undolistmodal, 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())
- Plugin:
Changed
minAppVersionbumped from 1.6.6 to 1.7.2 — required forWorkspace.revealLeaf()used by undo tree.
Fixed
g;/g,/g-/g+keymaps not working afterreloadFeatures()— the changelist and undo treemapCommandregistrations were only inonload()but not inreloadFeatures(). SincereloadFeatures()callsunregisterAll()(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 toreloadFeatures().- Plugin:
src/main.ts(added changelist + undo tree mapCommand calls toreloadFeatures())
- Plugin:
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 fixKNOWN_LIMITATIONS.md: Added undo tree sectionREADME.md: Added undo tree to features listCONTRIBUTING.md: Added undo-tree.ts, undo-tree-view.ts to codebase structureAGENTS.md: Added undo tree to page ownership tabledocs/features/undo-tree.md: New feature pagedocs/features/index.md: Added undo tree linkdocs/features/quality-of-life.md: Added g+/g- and :earlier/:later to change navigationdocs/reference/keybindings.md: Added undo tree navigation sectiondocs/configuration/settings.md: Added Undo tree settings groupdocs/configuration/vimrc.md: Added undotree/undofile optionsdocs/configuration/lua-config.md: Added vim.fn.undotree() and vim.opt entriesdocs/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 thefocusinlistener 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 arecentlyExitedguard (WeakRef+ 250ms cooldown) that suppresses thefocusinhandler 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(recentlyExitedWeakRef guard,markRecentlyExitedcooldown)
- Plugin:
- Lua text objects lost after
reloadFeatures()—vim.textobject.add()registrations from.obsidian.init.luawere silently discarded becauseloadLuaConfigInternal()calledreloadFeatures()after Lua evaluation, destroying theVimRegistrationinstance that held the keybindings. Fixed by persisting text object specs inluaTextObjectSpecs[]and re-registering them viareregisterLuaTextObjects()afterreloadFeatures()completes.- Plugin:
src/main.ts(luaTextObjectSpecs,registerLuaTextObject,reregisterLuaTextObjects)
- Plugin:
Added
- Subword motions (spider.nvim-style) —
w/b/e/geoverride stopping at camelCase, snake_case, and kebab-case word boundaries. Opt-in viaset 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)
- Plugin:
- 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
- Plugin:
- 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-DDwith rollover), CSS values (preserving unit), and integers. Priority-ordered rules (first match wins). Opt-in viaset 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
- Plugin:
- Custom text objects via Lua —
vim.textobject.add(keys, spec)andvim.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 withi(inner) ora(around). Invalid inputs produce error notices.- Plugin:
src/lua/textobject-api.ts
- Plugin:
- External grep binary integration — optional native ripgrep or GNU grep binary for the picker’s grep/live-grep sources. Supports
rg --json(structured output) andgrep -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
- Plugin:
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 fixKNOWN_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.tsfor 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: Fixedinsert_normalmode prompt default from(insert)toNORMAL(matchingsrc/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 (matchessrc/lua/fn.ts)README.md,docs/features/index.md: Updated “12 built-in picker sources” → 14docs/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 rowdocs/features/quality-of-life.md: Added change list navigation (g;/g,) sectiondocs/features/workspace-navigation.md: Made fold providers (frontmatter, callouts) more explicitKNOWN_LIMITATIONS.md: Updated “all 12 sources” → “all 14 sources” in cross-note jump list sectiondocs/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 viaset 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)
- Plugin:
- Flash jump mode (s) — bidirectional character jump bound to a configurable key (default:
s). Presss{char}to search both directions with labels. Disabled by default (set flashjumpto enable). Normal mode only — visualsretainscmapping.- Plugin:
src/flash/jump-mode.ts
- Plugin:
- Flash clever-f — when enabled (
set flashcleverf), pressingf{same-char}after a flash jump falls through to stockfbehavior (acts as;). Uses a 5-second timeout window.- Plugin:
src/flash/char-mode.ts(clever-f check),src/flash/state.ts(last search tracking)
- Plugin:
- Search match counter — hlslens-style
[3/15]indicator in the status bar showing the current match index and total count after/search andn/Nnavigation. 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)
- Plugin:
- 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 viaFlashLabeler. 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)
- Plugin:
- 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)
- Plugin:
flashMinPatternLengthsetting — 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
- Plugin:
- Match highlighting without labels —
showMatchHighlights()renders subtle position indicators for matches below theminPatternLengththreshold, distinct from label overlays.- Plugin:
src/easymotion/overlay.ts(showMatchHighlights),styles.css(.vim-motions-flash-match)
- Plugin:
- 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 viaset flashsearch/set noflashsearch.- Plugin:
src/flash/search-mode.ts(new),src/main.ts(registration with cleanup)
- Plugin:
- 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
- Fork:
Changed
- Flash labeler —
FlashLabelerclass with distance-based assignment (closest targets get home-row labels), label reuse across narrowing (labels stay stable as match set shrinks), and conflict skipping viaskipCharsset. - 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 fromhandleEscapeAndRedispatch)
- Plugin:
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, configurationdocs/features/index.md: Added flash motions linkdocs/features/easymotion.md: Added cross-reference to flashdocs/features/index.md: Updated flash description with incremental + search labelsdocs/configuration/settings.md: Added flash, flashmultiline, flashjump, flashjumpkey, flashcleverf, flashminpatternlength, flashsearch settingsdocs/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 sectionsKNOWN_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 labelsCONTRIBUTING.md: Added flash/ module, search-counter.ts, updated jump-mode.ts descriptionAGENTS.md: Updated flash motions page ownership with all settingsKNOWN_LIMITATIONS.md: Textarea Escape behavior updated — no longer re-dispatches to parent, symmetric context stack documentedREADME.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 exposingmatch_str,match_line,match_pos,replace, andtestmethods. Uses JavaScript’sRegExpengine (not Vim regex syntax). Returns 0-based byte offsets matching Neovim’svim.regex()convention. Invalid patterns raise a Lua error catchable withpcall.- Plugin:
src/lua/regex.ts(new),src/lua/api.ts(registration viainjectRegex)
- Plugin:
- Fengari fork:
__gcmetamethods via FinalizationRegistry —__gcmetamethods on userdata are now invoked when the userdata becomes unreachable from JavaScript. Registration happens atlua_setmetatabletime (only when the metatable contains__gc). Finalizers are drained at three points: outermostluaD_pcallreturn,collectgarbage("collect"), andlua_close. Errors in__gcare silently swallowed (PUC-Rio semantics). Finalization order is unspecified. Tables with__gcare not finalized (userdata only). Environments withoutFinalizationRegistrygracefully degrade (no registration, no errors).- Fork:
~/Repos/fengari/src/lstate.js(finalizer infrastructure onglobal_State,drainFinalizers,lua_closedrain + unregister),~/Repos/fengari/src/lapi.js(lua_setmetatablesplitLUA_TUSERDATA/LUA_TTABLE, FR registration),~/Repos/fengari/src/ldo.js(drain point inluaD_pcall),~/Repos/fengari/src/lbaselib.js(collectgarbage("collect")drain integration)
- Fork:
- Fengari fork:
collectgarbage()no longer crashes — all 8collectgarbagemodes now return safe values instead of throwingluaL_error("lua_gc not implemented")."count"returns0, 0(no memory tracking)."collect"drains the__gcfinalizer queue."isrunning"returnsfalse. All other modes return0. Previously, any Lua code callingcollectgarbage()crashed the entire init sequence.- Fork:
~/Repos/fengari/src/lbaselib.js
- Fork:
- Native JS error propagation via
lua_atnativeerror— the plugin now installs alua_atnativeerrorhandler that converts native JS errors (TypeError, RangeError, etc.) to extractable Lua strings. Previously, native JS errors thrown inside fengari C functions were pushed aslightuserdataand lost —lua_tolstringreturnednull, producing generic “Unknown Lua error” messages. The handler extractsError.message(orString(e)for non-Error values) and pushes it as a Lua string. Covers all threads including coroutines (handler is onglobal_State).- Plugin:
src/lua/engine.ts(lua_atnativeerrorhandler increateSandboxedState),src/lua/types.d.ts(lua_touserdata,lua_atnativeerror,lua_pushintegertype declarations)
- Plugin:
Changed
- Fengari fork:
sprintf-jsreplaced with custom formatter — thesprintf-jsnpm dependency (sole runtime dependency) has been replaced with a purpose-builtluaSprintffunction in the fork’ssrc/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
- Fork:
- Fengari fork: integers widened from 32-bit to 53-bit —
math.maxintegeris now9007199254740991(2^53 - 1). Arithmetic operations use full 53-bitNumberprecision.string.packsize("j")returns 8 (was 4).tonumber("1099511627776")now returns the integer (wasnil). 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
- Fork:
- 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_yieldkcontinuations with aCoroutineRunnermanaging thread lifecycle, instruction hooks, timeouts (10s), and concurrency limits (16 concurrent operations).pcallcorrectly catches async errors across yield/resume boundaries.- Plugin:
src/lua/coroutine-runner.ts(new:CoroutineRunner+AsyncRegistry),src/lua/engine.ts(evalLuaAsync,INSTRUCTION_LIMITexport),src/lua/types.d.ts(7 new fengari type declarations:lua_newthread,lua_resume,lua_yieldk,lua_status,lua_xmove,lua_isyieldable,LUA_YIELD)
- Plugin:
vim.ob.fs.read(path)andvim.ob.fs.readlines(path)— read vault files from Lua.readreturns a string,readlinesreturns a table of lines. Both yield internally via the coroutine bridge. Errors are catchable withpcall. Works in keymap callbacks, autocmd handlers, timer callbacks, and user commands. Also works at top level ininit.lua. Blocked in snippetf()/d()nodes (raises “async APIs cannot be called from snippet nodes”).- Plugin:
src/lua/obsidian-api.ts(read/readlinesC-functions),src/lua/loader.ts(fsReadcallback viaadapter.read+readExternalFilefor absolute paths),src/lua/api.ts(fsReadonVimApiCallbacks)
- Plugin:
require()for multi-file Lua configs —require('mymodule')loadslua/mymodule.luafrom the vault root. Dot-separated names resolve to subdirectories (require('utils.strings')→lua/utils/strings.lua). Modules are cached inpackage.loaded. Circular requires detected via sentinel. Security: path traversal (..), absolute paths, and backslash paths are rejected.- Plugin:
src/lua/package.ts(new:packagetable, sandboxedload(), Lua-implementedrequire()),src/lua/engine.ts(loadkept in disabled list with re-enable note)
- Plugin:
load(chunk)re-enabled with sandboxing —load()compiles a string chunk and returns the compiled function (ornil+ error).dofileandloadfileremain disabled. The instruction count hook applies to loaded code.- Plugin:
src/lua/package.ts(injectSandboxedLoad)
- Plugin:
evalLuaAsyncfor async init.lua execution — top-levelinit.luacode can now call async APIs likevim.ob.fs.read. The init.lua chunk runs inside a coroutine viaevalLuaAsync, which compiles on the main state and delegates toinvokeAsyncCapable.autocmdManager.activate()fires only after all yields complete.- Plugin:
src/lua/engine.ts(evalLuaAsync),src/lua/loader.ts(evalLua→await evalLuaAsync)
- Plugin:
- Callback sites refactored for async capability — all 4 Lua callback invocation sites now use
CoroutineRunner.invokeAsyncCapablewhen a runner is available, with fallback to the originallua_pcallpath 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)
- Plugin:
- Snippet async guard —
f()andd()snippet node evaluations are wrapped withrunner.setAsyncBlocked(true/false)to prevent async API calls during snippet expansion.- Plugin:
src/snippets/dynamic-bridge.ts(guards inrecomputeIfNeededandexpandDynamicSnippet)
- Plugin:
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__gcdrain, no-overhead without__gc, tables not registered, error swallowing, metatable nil/change unregister, recursive drain guard,lua_closedrain, 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_linealias,match_posfrom offset,replacewith captures + global flag,testboolean, flags (case-insensitive), invalid pattern error, missing pattern error - 8 spike tests in
~/Repos/fengari/test/coroutine-promise-bridge.test.js:lua_yieldkcontinuations,lua_isyieldable,pcallacross 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 entriesKNOWN_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;__gcitem 4 → Implemented (userdata via FinalizationRegistry); error message quality item 8 → Implemented (atnativeerror handler);collectgarbageitem 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 withvim.regex(), async file reading, multi-file configs, and__gcuserdata finalizationCONTRIBUTING.md: Addedregex.ts,coroutine-runner.tsandpackage.tsto codebase structureAGENTS.md: Updated fengari fork section — sprintf-js removed (zero deps), 53-bit integers,vim.regex(), async bridge, require(), load(),__gcvia FinalizationRegistry,collectgarbagesafe no-ops, native error propagation viaatnativeerrordocs/configuration/lua-config.md: Addedvim.regex()API reference section,vim.ob.fs.read/readlinesto fs table,require()andload()sections,collectgarbagebehavior, updated unsupported APIs list~/Repos/fengari/DIFFERENCES.md: Updated behavioral differences table (collectgarbage,__gc), updated inherited limitations (collectgarbage and__gcaddressed)~/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’srequestMeasureran in the same frame as the decoration change, before the browser reflowed the new DOM — causingcoordsAtPos()to read stale layout coordinates and the cursor to display the wrong character. Fixed in the codemirror-vim fork by addingfocusChangedto the block cursor’s redraw trigger and scheduling a deferredrequestAnimationFramere-measure on focus gain, ensuring the cursor reads post-reflow coordinates. (#71)- Fork:
~/Repos/codemirror-vim/src/block-cursor.ts(focusChangedtrigger, deferredrequestAnimationFramere-measure),~/Repos/codemirror-vim/src/index.ts(focus event handler oncontentDOM)
- Fork:
Added
- Replace-with-register operator (
gr{motion}) — implements thegroperator from vim-ReplaceWithRegister.gr{motion}replaces the text covered by {motion} with register contents, discarding the replaced text (register preserved). Supportsgrr(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 viaenableReplaceWithRegister),src/workspace/navigation.ts(conditionalgrn/grr/gra→<leader>rn/<leader>rb/<leader>rarelocation),src/settings.ts(enableReplaceWithRegistersetting in both UI versions),src/vimrc/loader.ts(replacewithregister/rwroptions),src/types/vim-api.d.ts(getRegister()type)
- Plugin:
enableReplaceWithRegistersetting — boolean toggle (default:true) gating thegroperator independently fromenableHardWrap. When enabled,grn/grr/graworkspace bindings are relocated to<leader>rn/<leader>rb/<leader>raunder a “Notes” which-key group. When disabled, legacygrn/grr/grabindings are restored. Configurable via Settings UI (both pre-1.13 and post-1.13),:set replacewithregister/:set rwrin vimrc, orvim.opt.replacewithregisterin Lua.
Tests
- 22 e2e tests in
test/specs/operators.e2e.tsfor replace-with-register:grr(single, multi-line, count),griw,gr$,grl,gri',gr}, named registers ("agriw,"a3grr), visualgr(charwise and linewiseV), 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 entryKNOWN_LIMITATIONS.md: Updatedgrreplace-with-register parity section — removed[count]grrand dot-repeat gaps (confirmed working), updated test coverage line; added block cursor refocus → FixedREADME.md: Added replace-with-register to features listCONTRIBUTING.md: Addedreplace-with-register.tsto codebase structure, updated workspace navigation descriptionAGENTS.md: Updated workspace navigation descriptiondocs/reference/keybindings.md: Added replace-with-register section, updated workspace nav with<leader>r*bindingsdocs/features/workspace-navigation.md: Updated migration note with new<leader>r*defaultsdocs/features/ex-commands.md: Updated default-key column for relocated commandsdocs/configuration/settings.md: AddedenableReplaceWithRegisterto Vim features groupdocs/configuration/vimrc.md: Addedreplacewithregister/rwrto boolean optionsdocs/configuration/lua-config.md: Addedreplacewithregisterto 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
cursorShapessettings. Previously, insert mode showed no cursor and normal mode showed a hollow block due to two issues: (1)cursorShapeswas not passed tocreateEmbeddableEditor()intable-cell-editor.ts, and (2) the cell editor’s CM6 instance does not receive.cm-focusedfrom Obsidian, causing the fork’s unfocused-cursor rule to apply. Fixed with a module-level setter (setCellEditorCursorShapes) wired in bothonload()andreloadFeatures(), apendingCursorShapesstash inembeddable-editor.tsto handle the super-before-assignment timing issue, and a dynamicCSSStyleSheet(viadocument.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(pendingCursorShapesstash),src/main.ts(setter calls + cleanup)
- Plugin:
- 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: inheritto.vim-table-cell-editor .cm-editor,font-size: inheritto.cm-content, andpadding: 0+line-height: var(--table-line-height, var(--line-height-tight))to.cm-line. (#19)- Plugin:
styles.css(table cell editor CSS)
- Plugin:
- 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 fromwrapper.textContent(the rendered DOM), which returns plain text without markdown syntax —[[note-a]]becamenote-a. Now reads raw markdown from the document source viagetCellDocumentRange(). (2) On cell editor close, the cell content was restored as plaintextContentwithout re-rendering markdown. Now usesMarkdownRenderer.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(acceptsrawMarkdownparameter,rerenderCellContent()on close),src/vim/table-nav-controller.ts(passes raw text from document)
- Plugin:
- Textarea vim overlay content not synced on rapid teardown — clicking “Save” via hint mode (
fkey) 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. ThehandleBlur()andhandleEscapeAndRedispatch()paths already calledsyncNow()before teardown, but the MutationObserver path (modal removed from DOM) went straight toteardownActive(). Fixed by adding asyncNow()call inteardownActive()immediately after cancelling the timer but before destroying the editor. (#69)- Plugin:
src/vim/textarea-vim-manager.ts(syncNowflush inteardownActive)
- Plugin:
Documentation
CHANGELOG.md: Added entries for embedded table cell editor cursor shapes, font size/line height, wikilink color fixes, and textarea sync-on-teardown fixKNOWN_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 → Fixeddocs/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 viagd/gD, picker file selection (all 12 sources), harpoon, oil, hint mode,:e/:find/:tabnew/:buffer/:bfirst/:blast, structural buffer cycling (]b/[b), and Luavim.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:jumpsex command displays the list. Newjumplist(boolean, default true) andjumplistsize(number, default 200) vim options.- Plugin:
src/vim/jumplist.ts(new:JumpListclass),src/workspace/navigate.ts(new:navigateWithJump/navigateWithJumpFile/navigateWithJumpSetActivewrappers),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)
- Plugin:
- 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 viahandleKey),src/vim/mode-tracker.ts(cell editor mode sync)
- Plugin:
ir/artable row text objects —irselects inner row content (between first and last|, excluding pipes),arselects the entire row including pipes. Works in raw markdown mode. Follows the same pattern asi|/a|cell text objects.- Plugin:
src/text-objects/table-row.ts(new),src/text-objects/register.ts
- Plugin:
Fixed
- Hint mode
Fon file explorer and other generic targets opens in current tab instead of new tab — pressingFin hint mode on a file in the left sidebar file explorer (.nav-file-title) opened it in the current tab, identical tof. ThehintOpenNew()function only passedopenInNewPane=trueforlinkandpanetarget types; all other targets (generic,button,input) fell through toopenInNewPane=false, bypassing the existing Ctrl+Meta click path that Obsidian interprets as “open in new tab”. SimplifiedhintOpenNew()to always passopenInNewPane=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(hintOpenNewsimplified to unconditionalopenInNewPane=true)
- Plugin:
jumpListWalkaction override lost afterreloadFeatures()— thedefineActionOverride('jumpListWalk', ...)applied duringonload()was wiped byreloadFeatures()(called during vimrc loading) becauseunregisterAll()restored the original action and the override was not re-registered. Fixed by adding the override toreloadFeatures()alongside the existingnewLineAndEnterInsertModeoverride.- Plugin:
src/main.ts(addedjumpListWalkoverride toreloadFeatures())
- Plugin:
- First character swallowed when entering table cell editor — pressing
iin table-nav mode opened the cell editor and immediately dispatchedhandleKey(adapter, 'i'), but the vim extension on the cell editor’s CM6 instance hadn’t finished initializing. The dispatchediwas either a no-op (vim not ready) or treated as typed text. Fixed by deferring thehandleKeydispatch viasetTimeout(fn, 0).- Plugin:
src/vim/table-nav-controller.ts(deferredhandleKeyinenterCellEdit)
- Plugin:
Tests
- 27 unit tests in
test/unit/jumplist.test.ts:JumpListclass — 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-notegd→<C-o>→<C-i>, jump list data structure verification,:jumpsmodal, jumplist setting toggle, deleted file resilience - 4 e2e tests in
test/specs/table-cell-vim-mode.e2e.ts:dir/dar/yirtable row text objects,irno-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
registerEditorExtensionin 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 modeFfile explorer fixKNOWN_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 modeF→ updated behavior table (all targets now open in new tab)README.md: Added cross-note jump list to features listCONTRIBUTING.md: Added jumplist.ts, navigate.ts, table-row.ts, jumplist-bridge.ts to codebase structuredocs/reference/keybindings.md: Added<C-o>/<C-i>jump list,:jumps,ir/artext objectsdocs/features/hint-mode.md: UpdatedFaction description to include file explorer and generic targetsdocs/features/tables.md: Added vim modality in cell editors section, table row text objectsdocs/configuration/settings.md: Addedjumplistandjumplistsizesettingsdocs/configuration/vimrc.md: Addedjumplist/jumplistsizeoptions
[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
minHeightwith fixedheight+maxHeightcopied from the original textarea’s computed size. When the original textarea used dynamic height (e.g.,height: autoor 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, andmaxHeight = max(effectiveHeight, 50vh)caps growth at half the viewport with scrollbar overflow. The wrapper’s CSSoverflowchanged fromhiddentoautoso content exceedingmaxHeightscrolls instead of being clipped. (#69)- Plugin:
src/vim/textarea-vim-manager.ts(adaptive height calculation withMIN_HEIGHT_PXfloor),styles.css(overflow: autoon.vim-motions-textarea-overlay)
- Plugin:
- 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
gand second keystroke (?,.,s,f), disrupting Oil’sg?help modal and otherg-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(onKeyPressGeneralOil context check)
- Plugin:
ci*marked as permanent Live Preview limitation — investigation found thatci*(change inside bold) works correctly in Live Preview for multi-character content. On the active line, Obsidian usesDecoration.mark(visible text nodes), notDecoration.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(unskippedci*test, now passing)
- Plugin:
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 afterg?, 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 fixKNOWN_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 → Fixeddocs/configuration/which-key.md: Updated Oil explorer context section — removed non-editor and “all” mode warningsdocs/features/text-objects.md: Removedci*limitation note if present
[0.61.0] - 2026-07-15
Fixed
- Hint mode
Fon pane targets opens in same tab instead of new tab — pressingFin hint mode on a pane target (.workspace-leaf-content) behaved identically tof(focus the pane) instead of opening the pane’s content in a new tab. ThehintActivate()function ignored theopenInNewPaneparameter fortargetType === 'pane', always callingsetActiveLeaf(). Now callsworkspace.duplicateLeaf(leaf, 'tab')whenopenInNewPaneis true. Link targets were unaffected —openLinkText()already used the parameter correctly. (#70)- Plugin:
src/ui/hint-mode.ts(hintActivatepane branch)
- Plugin:
j/kand other standard-gate keys not working in Bases views — Obsidian Bases views (.basefiles) use the view type"bases", which was missing from the defaultGLOBAL_NAV_VIEW_TYPESset. TheisPluginLeafActive()check treated Bases as a plugin view and blocked standard-gate keys (j/kscroll,H/Ltab switch, count-prefix digits). (#70)- Plugin:
src/workspace/global-key-handler.ts(added'bases'toGLOBAL_NAV_VIEW_TYPES),src/settings.ts(updated default list in description)
- Plugin:
Tests
- 1 new e2e test in
test/specs/hint-mode.e2e.ts:Fon pane target callsduplicateLeaf(spy-based verification) - 1 new e2e test in
test/specs/global-nav.e2e.ts:Hfrom bases view switches to previous tab (creates.basefile, verifies standard-gate key interception)
Documentation
CHANGELOG.md: Added entries for hint modeFpane fix and Bases view type fixKNOWN_LIMITATIONS.md: Updated hint mode target classification (paneF→duplicateLeaf); updated workspace navigation view type list to includebasesdocs/features/hint-mode.md: Updated pane target behavior descriptiondocs/features/workspace-navigation.md: Addedbasesto default view typesdocs/configuration/settings.md: Updated workspace navigation view types default listdocs/configuration/vimrc.md: Updated default view types in descriptiondocs/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 legacydisplay()method in the “Vim features” section. (#69)- Plugin:
src/settings.ts(addedenableVimTextareastoggle to legacy settings UI)
- Plugin:
- 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 addedoverflow: autoto the CM6 scroller, so content scrolls within the original textarea’s dimensions. (#69)- Plugin:
src/vim/textarea-vim-manager.ts(height+maxHeightinstead ofminHeight),styles.css(overflow: autoon.cm-scroller)
- Plugin:
- 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: inheritto.vim-motions-textarea-overlay .cm-editorso the overlay matches the original element’s font size. (#69)- Plugin:
styles.css(font-size: inheriton.cm-editor)
- Plugin:
- Removed
!importantfrom textarea hidden styles — the.vim-motions-textarea-hiddenCSS class used!importanton all four properties, which is not allowed by the project’s CSS conventions.- Plugin:
styles.css
- Plugin:
[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 syntheticinput/changeevents. Desktop only, disabled by default. (#69)- Plugin:
src/vim/textarea-vim-manager.ts(new),src/editors/embeddable-editor.ts(skipActiveEditoroption),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)
- Plugin:
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 tabledocs/configuration/vimrc.md: Addedvimtextareas/vtato boolean options tabledocs/configuration/lua-config.md: Addedvimtextareastovim.opttable
[0.59.0] - 2026-07-15
Fixed
- Absolute line number highlight not updating on cursor movement — when only absolute line numbers were enabled (
set numberwithoutset relativenumber), thevim-motions-line-num-currenthighlight (bold current line number) did not follow the cursor. ThelineMarkerChangecallback in both the standalone line number gutter and the unifiedstatuscolumngutter only checkedupdate.docChangedin absolute mode, ignoringupdate.selectionSet(cursor movement). Relative and hybrid modes were unaffected because they already includedupdate.selectionSet. The highlight only updated incidentally when entering special content (MathJax, images) that triggereddocChangedorviewportChanged. (#68)- Plugin:
src/vim/line-number-gutter.ts(lineMarkerChangeabsolute branch),src/vim/statuscolumn.ts(lineMarkerChange!hasRelativebranch)
- Plugin:
- 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 ranrealignTableLines()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.tsintercepted 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 CM6ViewPlugintracks cursor entry/exit from table ranges and dispatchesrealignTableLines()viaqueueMicrotaskwhen the cursor exits a dirty (edited) table. UsesAnnotation.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(addedrealignTableLines(),parseAlignments(),buildSepCell(),findTableBounds(), canonicalAlignmenttype),src/vim/table-auto-format.ts(deleted),src/vim/table-cursor-fix.ts(deleted),src/vim/table-operations.ts(refactored to use sharedrealignTableLines()),src/motions/tables.ts(refactored to use sharedrealignTableLines(), removed 5 duplicate helpers),src/vim/table-render-widget.ts(uses canonicalAlignmenttype),src/vim/table-nav-controller.ts(addedtableRealign()indoRefreshAfterOp()for embedded cell edit alignment),src/vim/table-cell-editor.ts(removedcreateTableAutoFormatExtensiondependency),src/motions/register.ts(removedtableAwareMoveUpoverride onk),src/main.ts(replaced auto-format/cursor-fix registration with format-on-exit)
- Root cause for both:
Changed
- Neovim-style modal styling for
GlobalExCommandModalandVimInfoModal— the ex command modal (:in non-editor views) and the info modal (:marks,:buffers,:registers, Oilg?) 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).GlobalExCommandModaluses a prompt-modal pattern with three styled sections (input, results, instructions) and two-column suggestion rows showing:command+ description. All 40+ ex commands now havedescriptionfields.VimInfoModaluses an info-modal pattern with an accent-bordered inner wrapper. Both patterns use--modal-background,--font-monospace, and--color-accentCSS variables for full theme compatibility.- Plugin:
src/ui/global-ex-command.ts(descriptionfield onGlobalExEntryandExSuggestion, prompt-modal container styling, two-columnrenderSuggestion),src/ui/vim-info-modal.ts(info-modal container styling, floating title, inner wrapper),styles.css(new prompt-modal and info-modal CSS sections)
- Plugin:
- Neovim-style modal styling extended to remaining modals —
OutlineModal(:outline/gO),SearchResultsModal(:vimgrep),ContextActionsModal(gra), andOilConfirmModal(Oil destructive commit) now use the same Neovim-inspired styling asGlobalExCommandModalandVimInfoModal.OutlineModalshows heading text + line number,SearchResultsModalshows filename + line preview,ContextActionsModalshows command name + command ID.OilConfirmModaluses 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
- Plugin:
Documentation
KNOWN_LIMITATIONS.md: Absolute line number highlight not updating on cursor movement → FixedKNOWN_LIMITATIONS.md: Table auto-formatting → updated to describe format-on-exit behavior; typing|cursor jump (#66) → Fixed; escaped\|handling (#67) → Fixeddocs/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()) intable-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)
- Plugin:
- Vimrc file I/O timing —
readVimrcFile()now usesstat()as a readiness probe beforeread(), 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()usesstat()instead of a fullread(). On retry exhaustion, a user-facing Notice is shown. ThevimrcLoadingflag is now wrapped intry/finallyso a failedloadVimrc()call no longer permanently blocks future retry attempts. The samestat()+retry pattern is applied toreadLuaFile()in the Lua config loader.- Plugin:
src/vimrc/loader.ts(readVimrcFile,fileExists),src/lua/loader.ts(readLuaFile),src/main.ts(try/finallyaround vimrc loading)
- Plugin:
- Oil which-key labels not appearing —
getCommandLabels()inOilKeybindingManagerreturned empty when oil bindings were not yet applied (theif (!this.applied) return []guard prevented labels from being registered duringrebuildWhichKey()). Labels are now always returned — they are static metadata fromOIL_MAPPINGS, valid regardless of whether the vim mappings are currently active.- Plugin:
src/oil/keybindings.ts(removedappliedguard fromgetCommandLabels())
- Plugin:
- 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 withwindow.setTimeout(0)yields)
- Plugin:
Changed
- Oil
g?help usesVimInfoModal—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:registerswhen the picker is disabled. Dismissible via Escape.- Plugin:
src/oil/keybindings.ts(showOilHelprewritten to useVimInfoModal),styles.css(removed.vim-motions-oil-help*CSS)
- Plugin:
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\|,:tablerealignpreserving\|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; surroundysdot-repeat with tag/function → clarified as working at runtime (test infrastructure limitation only); vimrc timing section updated with improved retry mechanismKNOWN_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
tableRenderFieldStateField’supdate()method has a “D12 guard” (activeEditTableRange) that, when set, maps old decorations instead of rebuilding them ondocChanged. This guard was correctly cleared for table operations (o,dd,J,K, etc.) viaexecuteTableOp()and for full table exit viaexitTable(), butexitCellEdit()— the path taken when closing a cell editor — never cleared it. The dispatch fromcloseCellEditor()triggereddocChangedwhile the guard was still active, causingprev.map(tr.changes)(position-only shift) instead ofbuildDecorations()(full HTML rebuild). Fixed by addingsetActiveEditTableRange(null)beforecloseCellEditor()and replacing the immediatehighlightCell()withrefreshAfterOp()— the same pattern used by all other table mutation paths. (#61)- Plugin:
src/vim/table-nav-controller.ts(exitCellEdit())
- Plugin:
- Visual-line mode highlight missing on replaced widget blocks — in visual-line mode (
V), the fork’slinewiseVisualHighlightViewPlugin usesDecoration.line()to highlight selected lines, but replaced widget blocks (MathJax$$, note embeds![[note]], plugin table widgets) have no.cm-lineelements in the DOM — CM6 silently drops line decorations for replaced ranges. Fixed by adding a plugin-sideLinewiseWidgetHighlightViewPlugin that scanscontentDOMdirect children for non-.cm-linewidget elements and togglescm-vim-linewise-widget-selectionon widgets whose document range overlaps the visual-line selection. Usesview.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-selectionrule),src/main.ts(extension registration)
- Plugin:
[0.56.0] - 2026-07-14
Added
- Surround
csf(change surrounding function name) —csfchanges the function name around the cursor. Prompts for the new function name via afunc:status bar prompt; press Enter to apply or Escape to cancel. Dot-repeat (.) replays with the saved name. Uses the samefindSurroundingFunctionasdsf(single-line only). Handles nested calls and method chains.- Fork:
~/Repos/codemirror-vim/src/vim.js(target === 'f'case in change operator,pendingInputprompt,funcResultfallback inhandleSurroundSubState)
- Fork:
- 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 theOIL_MAPPINGSarray and fed into the which-keycommandLabelsmap viagetCommandLabels().- Plugin:
src/oil/keybindings.ts(descfield onOilMapping,getCommandLabels()method),src/main.ts(oil labels injected intorebuildWhichKey())
- Plugin:
- Oil
g?help overlay — pressg?in Oil to toggle a help overlay listing all Oil keybindings with descriptions. Entries are derived fromOIL_MAPPINGSto prevent drift. Dismissible viag?(toggle) or Escape.- Plugin:
src/oil/keybindings.ts(g?mapping,showOilHelpmethod),styles.css(oil help overlay styles)
- Plugin:
- 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(imPresetsetting,IM_PRESETSdata, preset dropdown in both legacy and searchable settings UI)
- Plugin:
:IMToggle/:IMStatusex commands —:IMToggleenables/disables IM switching and saves the setting.:IMStatusqueries the current IM identifier and displays it via a Notice.- Plugin:
src/main.ts(registerImExCommands())
- Plugin:
- 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 firstInsertEnteruses the correct IM instead of the default.- Plugin:
src/im/im-switcher.ts(loadPersistedState(),getPersistedState()),src/settings.ts(persistedImStatefield),src/main.ts(load/save wiring)
- Plugin:
- Special marks in picker — the
:markspicker 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)
- Plugin:
:grepregex support —:grepnow uses JavaScriptRegExpfor pattern matching instead of Obsidian’sprepareSimpleSearch. Invalid regex patterns gracefully fall back to substring matching. This matches Neovim’s:grepbehavior where the pattern is a regex.- Plugin:
src/picker/sources/grep.ts(createMatcher()withRegExp+ fallback),src/picker/sources/live-grep.ts(same pattern)
- Plugin:
Changed
loadInitLua()parameter refactor — the function signature was refactored from 11 positional parameters to(app, vim, options?)with aLoadInitLuaOptionsinterface. All callers updated.- Plugin:
src/lua/loader.ts(LoadInitLuaOptionsinterface, destructured options),src/main.ts(caller updated)
- Plugin:
Fixed
- Vimrc loading reliability —
readVimrcFilenow retries with exponential backoff (50ms, 100ms, 200ms) when the vault adapter returns empty content during earlyactive-leaf-changeevents. The arbitrary 100ms safety-net timeout for map re-application has been removed. This addresses the intermittent issue wherenmap L $orset textwidthcommands were silently dropped on plugin load.- Plugin:
src/vimrc/loader.ts(readVimrcFileretry logic),src/main.ts(removed 100ms setTimeout)
- Plugin:
- 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.exmapdefinitions from the initial load persist (documented as known limitation).- Plugin:
src/main.ts(softReloadVimrc(),vimrcMapKeystracking,vault.on('modify')handler)
- Plugin:
- BufEnter initial fire destroying buffer-local keymaps — function-callback keymaps registered in
BufEnterautocmd handlers during the initial syntheticBufEnterwere destroyed by the subsequentreloadFeatures()→vim.resetKeymap()call. Fixed by deferring the initialBufEnterfire until afterreloadFeatures()andapplyLuaMaps()complete. TheAutocmdManagernow stores the initial file path inpendingInitialBufEnterPathand exposesfireInitialBufEnter()to fire it at the correct lifecycle point.- Plugin:
src/lua/autocmd.ts(pendingInitialBufEnterPath,fireInitialBufEnter()),src/main.ts(call after reload)
- Plugin:
- Blockquote fenced code block detection —
findFenceLinesnow 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_CLOSEregexes,blockquoteDepth())
- Plugin:
- 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 inlastEditInputState._asyncMotionTargetafterapplyOperatorsucceeds. During dot-repeat,repeatLastEditdetects this stored target and applies the operator directly instead of re-executing the async motion overlay.- Fork:
~/Repos/codemirror-vim/src/vim.js(_asyncMotionTargetstorage in async.then(),repeatCommand()offset replay)
- Fork:
- Surround
ysdot-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 (_ysTextObjectMotionand_ysTextObjectChar) inlastEditInputStatevia theonRepeatcallback. During dot-repeat,repeatLastEditre-evaluates the text object at the current cursor viatextObjectManipulation()and appliesaddSurroundToRange(). 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/_ysTextObjectCharinonRepeat,repeatCommand()text object replay),~/Repos/codemirror-vim/src/types.ts(3 newInputStateInterfacefields),~/Repos/codemirror-vim/DIFFERENCES.md
- Fork:
- 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 newgetSettingDefinitions()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()andgetSettingDefinitions())
- Lua runtime callbacks now have infinite loop protection — all 5 runtime
lua_pcallsites (function keymaps, user commands, autocmd handlers, timer callbacks, snippet f()/d() nodes) are now wrapped withwithInstructionGuard, which setslua_sethookwithLUA_MASKCOUNTbefore 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)
- Plugin:
- 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 samevault.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)
- Plugin:
- Surround
csbBysaBbchain now works —yswith text object motions (aB,iw,a", etc.) aftercsor standalone now correctly applies the surround. Theys_motionhandler directly evaluates text object motions instead of dispatching throughhandleKey→evalInput, whereclearInputStatewould lose theselectedCharacter. 74/74 nvim-surround golden comparison tests now pass (up from 73/74).- Fork:
~/Repos/codemirror-vim/src/vim.js(ys_motionhandler,operatorArgson surround state),~/Repos/codemirror-vim/src/types.ts(operatorArgsfield)
- Fork:
- Surround dot-repeat cross-type leak prevention — the surround dot-repeat guard was tightened to only use saved replacements when
_surroundTypematches the current operation type (cs,ys,yss), preventing stale state from a priorcsoperation from silently consuming a subsequentyscommand.- Fork:
~/Repos/codemirror-vim/src/vim.js(tightenedsavedReplacementguards with_surroundTypematch)
- Fork:
- Vimrc loading decoupled from CM adapter — vimrc parsing no longer requires a CM adapter.
loadVimrcnow uses a two-phase approach:readAndParseVimrcFilereads and parses the file without needing an editor (reusingparseVimrcfromparser.ts), thenapplyVimrcCommandsapplies all 14VimrcCommandtypes with explicit handling —exmapis applied eagerly (no CM needed), unknown commands deferred topendingExCommandsand 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, refactoredloadVimrc),src/main.ts(retry loop removed,pendingVimrcExCommandsfield, deferred application inactive-leaf-changehandler)
- Plugin:
Documentation
KNOWN_LIMITATIONS.md: EasyMotion dot-repeat → Fixed; surroundysdot-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;loadInitLuarefactor → Implemented; vimrc hot-reload → updated to soft-reload; vimrc loading reliability → updated with retry logic; tagcst/yst→ Verified;ci*Live Preview → Permanent;selectmode=mouse→ PermanentDIFFERENCES.md(fork): Addedcsfsection; updated surround summary; addedys_motiontext object fix; added async motion dot-repeat (_asyncMotionTarget); added surroundystext object dot-repeat (_ysTextObjectMotion)docs/features/oil-explorer.md: Addedg?to oil ex commands tabledocs/configuration/which-key.md: Added Oil explorer context section (fork mode only)docs/configuration/settings.md: Added IM preset row to Input method tabledocs/features/ex-commands.md: Added:IMToggleand:IMStatusto ex commands referenceCHANGELOG.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 CM6gutter()column instead of usingDecoration.line()+ CSS::afteroverlays. 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. UsesCompartment-based runtime reconfiguration —:set signcolumn=yes/auto/notakes effect without full feature reload.signcolumn=autonow causes layout shift when marks appear/disappear (matching Neovim behavior);signcolumn=yesalways 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 fromRELOAD_KEYS, dedicated handlers in both settings panels,enableMarkGutterdeprecated),src/vimrc/loader.ts(markguttermapped tosigncolumnviasideEffect),styles.css(gutter element styles replacing::afteroverlay)
- Plugin:
enableMarkGuttersetting deprecated — the booleanenableMarkGutterproperty is now optional with@deprecatedJSDoc. Existing settings are auto-migrated tosigncolumnviasettings-migration.ts. The property is kept in the interface for migration type safety only.
Added
- Dual line number display — new
linenumbermodeoption (hybrid/dual/dual-rel-abs) shows absolute and relative line numbers in separate side-by-side gutter columns.set number relativenumber linenumbermode=dualrenders absolute on the left, relative on the right (configurable viadual-rel-absfor the reverse). The defaulthybridmode 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(aliaslnm) in vimrc, orvim.opt.linenumbermode = "dual"in Lua.- Plugin:
src/vim/line-number-gutter.ts(dual compartments,resolveGutters()),src/vim/options.ts(linenumbermodeoption),src/settings.ts(setting + UI dropdown),src/vimrc/loader.ts(linenumbermode/lnm),src/main.ts(passlinenumbermodeto extension creation/reconfiguration)
- Plugin:
- Global vs local mark color differentiation — global marks (
A–Z) render in--text-mutedcolor, local marks (a–z) render in--text-accent. The first character of the label determines the CSS class (.vim-motions-sign-marker-globalor.vim-motions-sign-marker-local).- Plugin:
src/vim/sign-column.ts(SignMarker.toDOM()case detection),styles.css
- Plugin:
- Click-to-navigate on mark labels — clicking a mark label in the sign column gutter moves the cursor to that line. Uses
domEventHandlers.clickon the gutter, querying thesignColumnFieldRangeSet for markers at the clicked line.- Plugin:
src/vim/sign-column.ts(domEventHandlers.click)
- Plugin:
signcolumnwidth modes —signcolumnnow acceptsauto:Nandyes:Nsyntax (N = 1–4) to control sign column character width.set signcolumn=auto:3reserves 3 character slots when marks exist.set signcolumn=yes:2always 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 tostring),src/vimrc/loader.ts(removedvalidValues)
- Plugin:
statuscolumnAPI — Neovim-compatiblestatuscolumnoption 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), individualsigncolumn/number/relativenumber/foldcolumnsettings manage gutters independently. Configurable viavim.opt.statuscolumn = "%s %l %r %C"in Lua orset statuscolumn(aliasstc) in vimrc. Click handlers on sign and fold segments preserved.linenumbermodedeprecated —dualmaps tostatuscolumn = "%l %r". (#59)- Plugin:
src/vim/statuscolumn.ts(new: parser, compositeStatusColumnMarker, unified gutter,StatusColumnSpacer),src/vim/sign-column.ts(signColumnFieldextracted as standalone extension,SignMarker.labelpublic),src/vim/line-number-gutter.ts(computeLineNumber+getNumberwidthexported),src/vim/mark-gutter.ts(re-exportssignColumnFieldExtension),src/vim/options.ts(statuscolumnoption),src/settings.ts(statuscolumnsetting),src/vimrc/loader.ts(statuscolumn/stc),src/main.ts(standalonesignColumnFieldregistration,statusColumnCompartment,reconfigureStatusColumnGutter()),styles.css(statuscolumn segment styles)
- Plugin:
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
clipboardortextwidthsetting saved. The settings restoration loop duringonload()(lines 717-739 ofmain.ts, added in v0.53.0 by commit947c6a7) calledapplySettingOverride→reloadFeatures()beforeonload()had created its ownVimModeTrackerandGlobalKeyHandler.reloadFeatures()created these resources, thenonload()overwrotethis.modeTrackerandthis.globalKeyHandlerwith new instances — without destroying the first ones. The orphanedVimModeTrackerleft duplicateaddStatusBarItem()DOM elements in the status bar, and the orphanedGlobalKeyHandlerleft a duplicatekeydownlistener on the document (causing intermittent leader key failures). Fixed by adding aninitializingphase guard toapplySettingOverride— all 6 side-effect branches (5 gutter reconfigurations +reloadFeatures()) are suppressed duringonload(), matching the existingvimrcLoading/luaLoadingguard pattern. Settings mutations tothis.settingsstill apply immediately; only premature side effects are blocked. (#63)- Plugin:
src/main.ts(initializingflag, 6 guard site updates inapplySettingOverride)
- Plugin:
iterateEditorViewscrash on non-editor leaves —reconfigureLineNumberGutter()and other gutter reconfigure methods could throwview.dispatch is not a functionon leaves where the CM6 EditorView chain resolved to a non-EditorView object. Fixed by adding atypeof cm.dispatch === 'function'guard initerateEditorViews.- Plugin:
src/main.ts(iterateEditorViewstype guard)
- Plugin:
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, nodata-vim-marksattribute - 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 orderdocs/configuration/settings.md: updated sign column description, added gutter layout tip with ASCII art exampledocs/configuration/lua-config.md: expanded hybrid line numbers tip with gutter layout exampleKNOWN_LIMITATIONS.md: updatedsigncolumnsection — removed zero-width overlay note, updated behavior descriptionREADME.md: updated marks feature description
[0.54.0] - 2026-07-13
Added
- Which-key auto-resolves Obsidian command names for
:obcommandmappings — when a key is mapped to:obcommand <id><CR>or:ob <id><CR>without an explicitdesc, 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”. Explicitdescoptions 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_REregex matching both literal spaces and<Space>notation),src/ui/global-which-key.ts(describeAction()obcommand resolution)
- Plugin:
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 filessrc/util/editor.ts:getEditorView(view)— extracts CM6EditorViewfromMarkdownView, replacing 6 inline casts across 5 filessrc/util/leaf.ts:getLeafId(leaf),isLeafPinned(leaf),getViewFilePath(view),getViewFileBasename(view)— 12 casts across 3 filessrc/util/metadata.ts:getResolvedLinks(app)— 2 casts across 2 filessrc/util/vault.ts:getVaultConfig(app, key),isBuiltinVimEnabled(app)— 4 casts across 4 filessrc/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/:obcommandshort/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:oband:obcommand(2 tests), explicitdescpriority (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.setandvim.obsidian.leader.setwith adescoption 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 thevim-keypressevent emits"<Space>"but the overlay compared against the raw" "character. (#58)- Plugin:
src/ui/which-key.ts(normalizeVimKey()function mirroring the fork’snormalizeKeyString,normalizedLeaderKeyfield for key event comparison, normalized lookups inshowLeaderBindings/showCompletions/onKeyPressLeaderOnly),src/main.ts(rebuildWhichKey()normalizes allcommandLabelsandgroupLabelsmap keys from settings, vimrc, and Lua sources)
- Plugin:
vim.opt.clipboardsilently ignored in init.lua — settingvim.opt.clipboard = "unnamed"or"unnamedplus"in init.lua had no effect. The Luavim.opthandler only routed options through theKNOWN_SET_OPTIONStable, butclipboardwas special-cased in the vimrc loader and missing from that table. Yanks never synced to the system clipboard when configured via Lua. Same issue affectedvim.opt.textwidth. (#56)- Plugin:
src/vimrc/loader.ts(SideEffectOpttype,clipboard/textwidth/guicursoradded toKNOWN_SET_OPTIONS),src/lua/api.ts(special-case blocks removed, unifiedKNOWN_SET_OPTIONSpath),src/main.ts(initial settings load from saved values),src/lua/loader.ts(setOptioncallback)
- Plugin:
- 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.clipboardandthis.settings.textwidthwere never pushed tovim.setOption()during plugin load. Fixed by applying saved side-effect options afterregisterVimOptions().- Plugin:
src/main.ts(initial settings loop usingKNOWN_SET_OPTIONSsideEffects)
- Plugin:
Changed
- Vim option architecture:
SideEffectOpttype — options that require side effects beyondthis.settings[key] = value(clipboard, textwidth, guicursor) are now declared in theKNOWN_SET_OPTIONStable with asideEffecttype and anapply()callback. Previously, these options were special-cased with separateifblocks in both the vimrc loader and the Luavim.opthandler — 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(SideEffectOptinterface,applyKnownSetOptionsideEffect handling, special-case blocks removed),src/lua/api.ts(unifiedKNOWN_SET_OPTIONSpath)
- Plugin:
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,:snippetspicker). 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 thecontextoption in Lua DSL. - Choice node cycling —
Ctrl+N/Ctrl+Pcycle through choice options (${1|a,b,c|}) on active snippet fields. - Snippet picker —
:snippetsopens the telescope-style fuzzy finder with snippet preview.:snippet nameexpands by name. @codemirror/autocompletefork — recursive descent snippet parser replacing CM6’s regex parser, supporting choice nodes, nested placeholders, transforms (parsed, not applied), and bare$1syntax. Fork at saberzero1/autocomplete.- 4 new settings: Enable snippets, Bundled snippets, Snippet directory, Trigger mode
- 4 new vimrc
setoptions: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,allmode desc,vim.obsidian.leader.setdesc - 11 new e2e tests in
test/specs/lua-config.e2e.ts:vim.opt.clipboard = "unnamed"applies from init.lua (1 test)yy/yw/ddpopulate+register when clipboard set via Lua (3 tests)vim.opt.textwidthandvim.opt.twalias 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 nocursorlinewithcursorlineopt(number/line/both). Compartment-based runtime switching. - Fold column —
set foldcolumnshows ▸/▾ indicators for foldable regions with click-to-fold. Uses CM6foldable()/foldedRanges()APIs. numberwidthoption —set numberwidth=Ncontrols 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 CM6gutter()withCompartment-based runtime switching —:set number/:set nonumbertake 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(aliasnu),set relativenumber/set norelativenumber(aliasrnu) - 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 emitssource-registered/source-unregisteredevents. Consumer plugins discover the API viawindow.VimMotions.pickerorapp.plugins.plugins['vim-motions'].pickerAPI.- Lifecycle:
vim-motions:picker-readyworkspace event fires after API installation; consumers useonLayoutReady+ event listener pattern for load-order safety. - Type definitions:
src/picker/picker-api.d.tsships 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)
- Lifecycle:
- 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(:Pickerex command),src/main.ts(picker-pickersObsidian command)
- Plugin:
- Picker source metadata —
PickerSourceinterface extended with optionaldisplayName,icon,description, andpriorityfields. 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)
- Plugin:
- Bundled picker integrations — three built-in picker sources that auto-detect and integrate with popular plugins:
- Omnisearch (
omnisearch) — dynamic full-text vault search viaglobalThis.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 viaobsidian-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)
- Omnisearch (
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-guttersin cell editors via CSS. (#19)- Plugin:
styles.css
- Plugin:
Changed
enableMarkGuttermigrated tosigncolumn— the boolean toggle is now a dropdown with Auto/Always/Off modes matching Neovim’ssigncolumnoption. Existing settings are auto-migrated.set markgutter/set nomarkgutterremain as backward-compatible aliases.- Mark gutter internals refactored — the mark gutter implementation has been extracted into a dedicated
sign-column.tsmodule. The rendering approach is unchanged (line decorations + CSS::afteroverlay, 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)
- Plugin:
Documentation
docs/configuration/settings.md: addednumberwidth,cursorline,cursorlineopt,signcolumn, andfoldcolumnsettingsdocs/configuration/vimrc.md: added new gutter options andmarkgutteralias to options tablesdocs/configuration/lua-config.md: added new gutter options tovim.opttabledocs/features/marks.md: updated to mentionsigncolumnas the canonical way to toggle mark indicatorsKNOWN_LIMITATIONS.md: added notes onsigncolumnoverlay behavior andcursorlineopt=screenlinesupportdocs/configuration/settings.md: added Line numbers settings groupdocs/configuration/vimrc.md: addednumber/nu,relativenumber/rnuto boolean options tabledocs/configuration/lua-config.md: addedvim.opt.number,vim.opt.relativenumberwith hybrid mode tipKNOWN_LIMITATIONS.md: removednumberandrelativenumberfrom “not implemented” options listdocs/development/picker-api.md: new provider API reference with consumer guide, API surface, integration examples (Omnisearch, Tasks), and lifecycle documentationdocs/development/index.md: added picker provider API linkdocs/reference/keybindings.md: added:Picker/:Pickto ex commands tableKNOWN_LIMITATIONS.md: added picker provider API pop-out window limitationdocs/configuration/settings.md: added Third-party integrations settings groupdocs/development/picker-api.md: added bundled integrations sectioneslint.config.mts: added.obsidian-cacheto global ignores (downloaded community plugin JS files)wdio.conf.mts: added Omnisearch, Tasks, Dataview asenabled: falseplugins for integration testingtest/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 usedstate.doc.lineAt(sel.to).toto find the end of the highlight, but the codemirror-vim fork’sexpandSelectionToLine()setssel.toto the start of the next line (viacurEnd.line++). CallinglineAt()on that position resolved to the entire next line, extending the highlight one line too far. Fixed by usingsel.todirectly 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(attachYankHighlightlinewise range calculation)
- Plugin:
[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. Setvim.obsidian.im.auto = falseto 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 theexternal-fs.tspattern. - 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)
- Lua API:
CmdlineEnter/CmdlineLeaveautocmd events — fire when entering/leaving the:,/, or?command-line prompt. Event data includescmdtype(":","/", or"?").CmdlineLeaveis 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
- Plugin:
Documentation
docs/configuration/lua-config.md: addedvim.obsidian.imAPI section with function reference and Lua examplesdocs/configuration/settings.md: added “Input method” settings group tableKNOWN_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.markranges over the same document region covered by the table’sDecoration.replaceblock 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 thetableRenderFieldStateField inPrec.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 ontableRenderField)
- Plugin:
[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
TransactionExtenderfor CM6 fold effects and aMutationObserverfor the properties widget’s CSS class toggle. (#54)- Plugin:
src/vim/fold-sync.ts(new),src/main.ts
- Plugin:
- Fold create/delete commands —
zf{motion}creates a manual fold over the motion range (works in both visual and operator-pending mode).zd/zDdelete the fold at the cursor.zEeliminates all folds in the document.- Ex commands:
:folddelete(zd),:foldeliminate(zE) - Plugin:
src/fold/commands.ts(new),src/operators/register.ts
- Ex commands:
- Incremental fold level —
zmfolds one more heading level (h1 first, then h2, etc.).zrunfolds one heading level. A customStateFieldtracks 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
- Ex commands:
- Markdown fold provider — custom
foldServiceregisters frontmatter (---blocks) and callouts (> [!type]) as foldable regions. These are now foldable viazc/zo/zain addition to the standard CM6 heading and code block folds.- Plugin:
src/fold/provider.ts(new),src/main.ts
- Plugin:
- 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
- Plugin:
- Fold-aware navigation — when enabled, navigating into a folded section (e.g.,
]hto a folded heading) automatically unfolds it. Matches Neovim’s defaultfoldopenbehavior. Configurable via Settings → Vim Motions → Fold-aware navigation (default: on).- Plugin:
src/vim/fold-sync.ts,src/settings.ts
- Plugin:
- 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
- Plugin:
Documentation
docs/features/workspace-navigation.md: added fold provider, placeholder, fold-aware navigation, and persistence documentationdocs/reference/keybindings.md: addedzf,zd,zD,zE,zm,zrkeybindingsdocs/features/ex-commands.md: added:folddelete,:foldeliminate,:foldmore,:foldlessdocs/configuration/settings.md: added Fold-aware navigation and Fold persistence settingsKNOWN_LIMITATIONS.md: addedzn/zNas known deviations; updated fold command coveragetest/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.whichkeysortin Lua, orset whichkeysort=<order>(aliaswks) in vimrc.- Plugin:
src/ui/which-key.ts(sortWhichKeyEntries,WhichKeySortOrdertype),src/ui/global-which-key.ts,src/settings.ts,src/main.ts,src/vim/options.ts,src/vimrc/loader.ts
- Plugin:
- 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:
whichKeyIconssetting (default: on). Configurable via Settings UI,vim.opt.whichkeyiconsin Lua, orset whichkeyicons/set nowhichkeyiconsin 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 withicon?/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
- Global toggle:
- Harpoon-style file pinning — pin files to numbered slots for instant switching.
<leader>hapins,<leader>1–<leader>9jumps to slots,<leader>hpopens 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
- 6 ex commands:
Documentation
docs/configuration/which-key.md: added Sort order and Icons sections with Lua/vimrc/Settings examples, color table, and default iconsdocs/configuration/settings.md: added Which-key sort order and Which-key icons rows to Which-key hints tabledocs/configuration/vimrc.md: addedwhichkeysort(wks) andwhichkeyicons(wki) optionsdocs/configuration/lua-config.md: addedwhichkeysortandwhichkeyiconsoptions tovim.opttableKNOWN_LIMITATIONS.md: added Sort order and Icons subsections to Which-key overlay sectiondocs/features/harpoon.md: new feature pagedocs/features/index.md: added harpoon to Jump navigation sectiondocs/configuration/settings.md: added Harpoon file pinning to Jump navigation tabledocs/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::afterpositioning — 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
- Plugin:
- Global mark persistence — marks
A–Zare persisted across files and plugin restarts. SettingmAstores the file path and cursor position; navigating to'Afrom 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(persistedMarksfield)
- Plugin:
- Enhanced marks picker —
:marksand<leader>fmnow 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 aMarkProviderabstraction for future extensibility (harpoon-style file marks).- Plugin:
src/picker/sources/mark-providers.ts(new:MarkProviderinterface,VimBufferMarkProvider,GlobalMarkProvider),src/picker/sources/marks.ts(rewritten),src/picker/types.ts(groupfield),src/picker/picker.ts(group header rendering)
- Plugin:
- Picker group headers —
PickerIteminterface extended with optionalgroupfield. 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
- Plugin:
Fixed
:delmarksnot refreshing gutter — deleting marks via:delmarks adid not update the mark gutter indicators because the plugin’s ex command handler didn’t trigger a gutter refresh. Fixed by adding anonMarksChangedcallback tocreateDelmarksCommandthat schedules a gutter refresh.- Plugin:
src/workspace/commands.ts,src/main.ts
- Plugin:
- 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 callsattachYankHighlight()(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(reloadFeaturescallsattachYankHighlight())
- Plugin:
Documentation
docs/features/marks.md: new feature page covering mark gutter indicators, global mark persistence, and grouped marks pickerdocs/features/index.md: added marks entry to Quality of life sectiondocs/configuration/settings.md: added Mark gutter indicators row to Vim features tabledocs/reference/keybindings.md: updated:marksdescription to reflect grouped pickerdocs/features/quality-of-life.md: added “Yank highlight” section with mode descriptions, configuration, CSS override tip, and fork-mode-only calloutdocs/features/index.md: added yank highlight to Quality of life bulletdocs/configuration/settings.md: added Yank highlight and Yank highlight duration rows to Vim features table; added CSS override tip calloutKNOWN_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-accentand can be overridden with the--vim-motions-yank-bgCSS custom property. Respectsprefers-reduced-motion. Replaces the external obsidian-vim-yank-highlight plugin. (#53)- Works with remapped yank keys (detects actual yank operations via the
vim-yankevent, 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-yankevent - Blockwise yank highlight deferred to a future release
- Plugin:
src/vim/yank-highlight.ts(new),src/main.ts,src/settings.ts,styles.css
- Works with remapped yank keys (detects actual yank operations via the
- 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/lmoves a cell highlight across the rendered table.j/kat the top/bottom row exits the table. - Cell editing:
i/a/c/s/Enteropens a vim-enabled editor in the highlighted cell with full vim support (modes, motions, text objects, auto-formatting).Escapereturns to table navigation; a secondEscapeexits the table.Tab/Shift-Tabmoves 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=embeddedin vimrc orvim.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
- Table navigation:
Changed
- Oil explorer architecture rewritten — oil no longer creates temporary
oil~*.mdfiles in the vault. The directory listing is rendered in a dedicatedoil-explorerview 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 viagetState()/setState().- New: reusable
EmbeddableMarkdownEditorabstraction (src/editors/embeddable-editor.ts) — extracts Obsidian’s internalScrollableMarkdownEditorprototype viaapp.embedRegistryand 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:
OilViewcustom view (src/oil/oil-view.ts) — extendsViewwith 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/qin oil now restore the previously open file instead of leaving an empty workspace. - Changed: Oil keybinding registration uses
vim.mapwith<CR>notation instead ofvim.noremapwith 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,tempToDirmap,getTempFilePath(),forgetTempPath(),cleanupOrphanedTempFiles(),forceSourceMode(),userIgnoreFiltersmanagement, CSS.nav-file-title[data-path^='oil~']hiding rule. - Migration: Legacy
oil~*.mdfiles 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)
- New: reusable
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: 0of its container, causing the bottom rows to be obscured by Obsidian’s status bar. The overlay now detects the status bar height and addspadding-bottomto 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 withgetBoundingClientRectadjacency check),src/ui/global-which-key.ts(same padding for global which-key)
- Plugin:
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 vianmap key :excommand<CR>in vimrc orvim.keymap.set('n', 'key', ':excommand<CR>')in Lua. - Plugin:
src/keybindings/action-registry.ts(new:exCommandFromMotion/exCommandFromActionhelpers),src/motions/register.ts,src/workspace/navigation.ts,src/main.ts
- 46 new ex command aliases for editor-context actions: structural navigation (
- 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/OilLeaveautocmd events — fire when entering/leaving an oil buffer, enabling Neovim-style buffer-local keymaps- Oil defaults now registered as
vim.noremapmappings pointing to ex commands (previouslymapCommand), making them visible in:mapoutput 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
- 9 oil ex commands:
- Picker keybinding configurability — picker modal keybindings (
<C-n>,<C-p>,<C-x>,<C-v>,<C-t>,<C-d>,<C-u>) are now configurable via Luavim.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/kscroll,H/L,:) are now remappablevim.obsidian.keymap.set/delnow operate on the liveGlobalMappingRegistryat 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
namefields 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.mdwith examples for all 4 remapping contexts (editor, oil, picker, global)
Changed
:gmap(display) renamed to:gmaps— the:gmapcommand now creates global keybindings instead of displaying them. Use:gmapsto list all active global mappings.- Autocmd events — 15 → 17 supported events (added
OilEnter,OilLeave)
Documentation
docs/configuration/remapping.md: new unified remapping guidedocs/features/oil-explorer.md: added remapping section with ex commands table, Lua examples, vimrc examples, andvim.obsidian.oilfunction referencedocs/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 linkdocs/configuration/lua-config.md: addedvim.obsidian.oilnamespace (10 functions),OilEnter/OilLeaveautocmd events,vim.obsidian.pick_keymap()APIdocs/configuration/vimrc.md: updated:gmap/:gunmap/:gmapsdocumentationdocs/configuration/index.md: added remapping guide to quick linksKNOWN_LIMITATIONS.md: updated keybinding remappability section to “Implemented” across all contexts; updated autocmd event count to 16AGENTS.md: updated change-to-page routing table withconfiguration/remapping.md
[0.45.0] - 2026-07-09
Fixed
gk/gjtakes extra keypress to traverse non-wrapped headings —gkrequired two presses to cross a heading line that was visually tall (large font/line-height) but did not wrap. CM6’smoveVerticallysaw the heading’s line block as spanning multipledefaultLineHeightsteps, causing a spurious within-line cursor move before crossing to the adjacent line. The fork’sfindPosVnow detects whenmoveVerticallystays on the same document line with negligible Y-coordinate change (less than halfdefaultLineHeightviacoordsAtPoscomparison) and force-moves to the adjacent document line. Legitimate wrapped-line navigation (Y delta ≥ threshold) is unaffected. (#26)- Fork:
src/cm_adapter.ts(findPosVY-delta spurious move detection)
- Fork:
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) +:wcreates a file,dd+:wdeletes,cw+:wrenames. Filenames without an extension default to.md. Names ending with/create folders. Renames update backlinks viaapp.fileManager.renameFile(). Deletes respect user trash settings viaapp.fileManager.trashFile(). - Cross-directory moves:
ddin one oil buffer,pin another,:wmoves 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,qclose,<C-l>refresh,g.toggle hidden files,gscycle 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/:updatedispatch: active file path is checked for theoil~prefix — oil commits route through the diff/validate/execute pipeline; normal files save normally- Global ex command:
:Oilavailable in the non-editor:command modal (same pattern as picker commands) - Setting gate:
:Oilcommand and keybindings only registered when theoilExplorersetting 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(:wdispatch,:Oilregistration),src/main.ts(OilManager/OilKeybindingManager lifecycle),src/settings.ts(4 settings),src/ui/global-ex-command.ts(:Oilin 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/oilClosecallbacks),src/lua/obsidian-api.ts(vim.obsidian.oilsub-table),src/lua/loader.ts(callback wiring)
- Plugin:
- Oil explorer settings — 4 new settings in Settings → Vim Motions → File explorer:
oilExplorer(toggle, default: on) — enable/disable the oil exploreroilShowHiddenFiles(toggle, default: off) — show dotfiles in oil viewsoilConfirmDeleteThreshold(slider, 1–20, default: 1) — confirmation dialog thresholdoilDefaultSort(dropdown: name/mtime/size, default: name) — directory sort order- Plugin:
src/settings.ts
- Oil explorer e2e test suite — 10 regression tests covering:
:Oilopens 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
- Plugin:
wdio.conf.mtsworkspace cleanup —onPreparehook deletes staleworkspace.jsonbefore 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~"]) instyles.css, and from search/graph/quick switcher via Obsidian’suserIgnoreFiltersmechanism. 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 detailsdocs/features/index.md: added oil explorer entry to workspace & commands sectiondocs/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 limitationKNOWN_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
nucleoandautopicker engine options are no longer available. The bundled wasm-bindgen glue code contained afetch()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 andauto/nucleoengine options removed),src/settings.ts(pickerMatcherEnginetype narrowed to'ufuzzy' | 'obsidian', dropdown options reduced),esbuild.config.mjs(WASM binary loader plugin removed),package.json(nucleo-matcher-wasmdependency 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)
- Plugin:
Changed
- Picker matching engine — setting reduced from four options (
ufuzzy,nucleo,obsidian,auto) to two (ufuzzy,obsidian). Default remainsufuzzy. - 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 usesvar(--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)
- Plugin:
- 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 withvar(--text-muted)color and avar(--modal-background)background to mask the border behind them.- Plugin:
src/picker/picker.ts(formatTitlehelper,.vim-motions-picker-sectionwrapper divs with.vim-motions-picker-titlespans for input, results, and preview sections),styles.css(.vim-motions-picker-section,.vim-motions-picker-titlerules, updated flex layout for preview body wrappers)
- Plugin:
- 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(renderMarkdownPreviewpositional branch rewritten to emit<pre>with per-line<div>elements),styles.css(.vim-motions-picker-preview-code,.vim-motions-picker-preview-code-linerules)
- Plugin:
Fixed
- Neovim golden recorder produced incorrect results for visual-block operations — the
NeovimClient.input()method usednvim_feedkeyswith'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/Vmode switches produced incorrect deletion scopes. Fixed by using:execute "normal ..."(vianvim.command()) for key sequences containing<C-v>, which processes synchronously within Neovim’s command loop. Non-block sequences still usenvim_feedkeys(needed for macro recording/replay which:normaldoesn’t support). AddedescapeForNormal()helper to convert control characters to Vim\<...>notation.- Plugin:
test/neovim/client.ts(hybridinput()method,escapeForNormalfunction) - 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.jsonandselect-mode-extended.json(minor corrections from improved key processing)
- Plugin:
- 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 inreadLinesAroundPositionand 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(getFrontmatterEndhelper,effectiveStartclamping inreadLinesAroundPosition)
- Plugin:
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.tsxranks aboveheader/utils.tsfor 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
matchLiteralIndexedWithIndicesandmatchPatternIndexedWithIndicesmethods for efficient WASM boundary crossing. - obsidian (opt-in): Obsidian’s built-in
prepareFuzzySearchAPI. 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(pickerMatcherEnginesetting),esbuild.config.mjs(WASM binary loader plugin)
- uFuzzy (default): Pure JavaScript matcher with filename-aware ranking — prefers exact filename prefix matches over partial path matches (e.g.,
- 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(filePickerSortfunction, 3-phasefilter()→info()→ custom sort pipeline)
- Plugin:
- Matcher benchmark suite —
npm run test:benchruns 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:benchscript)
- Plugin:
- 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)
- Plugin:
- 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
indicesToRangesandutf32ToUtf16Indices.- Plugin:
test/unit/picker/matcher.test.ts(68 tests, up from 18)
- Plugin:
- 3 Neovim golden comparison cases for
gkcolumn 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/gjcolumn drift on heading lines” section documenting the pixel-vs-character column deviation from Neovim with measurement data table; addedgj/gkcolumn 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 sectionAGENTS.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 forgkcolumn preservation across heading lines (pixel-basedposAtCoordsvs Neovim’s character-basedcurswant)- Fork
DIFFERENCES.md: updated “Widget-aware vertical navigation” section with clamp-all-jumps approach andposAtCoordscolumn 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==withh/lskipped 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 theEditorState.transactionFilterintroduced 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, theformattingMarkModesetting, and theformattingmarkmodevim option have been removed. (#33)- Plugin: removed
src/vim/formatting-mark-fix.ts,src/vim/formatting-mark-ranges.ts; removedformattingMarkModefrom settings interface, defaults, settings UI, Style Settings definition, vimrc loader, and vim options
- Plugin: removed
gk/gjstill skips lines in documents with mixed headings and lists —gkcould 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-basedmoveVertically. The fork’sfindPosVnow clamps all multi-document-line jumps to ±1 when no fold is present, regardless of decoration type.posAtCoordsresolves horizontal position on the clamped target line; thegoalColumn > 0guard is relaxed togoalColumn != nullso the column fixup also fires at column 0. (#26)- Fork:
src/cm_adapter.ts(findPosVline-jump clamp,posAtCoordsresolution on clamped target)
- Fork:
Added
- 6 regression tests for cursor movement through double-character formatting marks (
**,__,~~,==) — asserts every position is visited in bothlandhdirections - E2E tests for
gkover h4/h5/h6 headings: cursor horizontal position preserved across all heading levels - E2E test for
gkthrough 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; updatedgj/gkwidgets 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
enableOnMobilesetting (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(enableOnMobileinVimMotionsSettingsinterface,DEFAULT_SETTINGS,getSettingDefinitions()Mobile group,display()Mobile toggle),src/main.ts(early return inonload()whenPlatform.isMobile && !enableOnMobile,toggle-enable-on-mobilecommand registered before the gate)
- Plugin:
showConfigNotificationssetting — 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 isluabut no init.lua exists) always show regardless of this setting.- Plugin:
src/settings.ts(showConfigNotificationsinVimMotionsSettingsinterface,DEFAULT_SETTINGS, toggle in Vimrc & key bindings group),src/main.ts(notification gating in vimrc loading, lua loading, and dual-mode fallback)
- Plugin:
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
vimrcbut 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 newshowConfigNotificationssetting. Error notifications (lua parse/runtime errors) always show.- Plugin:
src/main.ts(vimrc notification block, lua notification block, dual-mode fallback notification)
- Plugin:
- 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 viapointer-events: none). Positional previews (grep, live grep, headings, marks) use a line-number gutter that highlights the target line.Componentlifecycle 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(renderMarkdownPreviewmethod,Componentlifecycle,PreviewResultdispatch),src/picker/types.ts(PreviewResultinterface,PreviewReturnunion type),src/picker/sources/preview-utils.ts(returnsPreviewResultwithsourcePathand optionallineRange),styles.css(rendered preview content, positional gutter, fixed modal height)
- Plugin:
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
TableRenderWidgetusedtextContentto populate cells, which strips all markup. Replaced withMarkdownRenderer.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 byMarkdownRendereris unwrapped to avoid block-level spacing in cells.Componentlifecycle is managed per widget (load()intoDOM,unload()indestroy) to prevent memory leaks.editorInfoFieldprovidesappandsourcePathfrom the editor state for correct relative image path resolution. (#50)- Plugin:
src/vim/table-render-widget.ts(renderCellfunction,MarkdownRenderer.render()integration,Componentlifecycle,editorInfoFieldfor app/sourcePath access)
- Plugin:
:obcommandunavailable in Lua-only config mode —vim.cmd('obcommand ...')failed with “Not an editor command” whenconfigModewas set tolua(without vimrc). Theobcommandex command was only registered insideregisterVimrcExCommands(), which only runs when vimrc loading is enabled. Movedobcommandregistration toregisterObCommand()alongsideob, sharing the same handler. Both commands are now available in all config modes (lua, vimrc, lua-vimrc, settings-only). Additionally,:obcommandwith no arguments now opens the command picker (matching:obbehavior) instead of silently doing nothing.- Plugin:
src/workspace/commands.ts(registerObCommandregisters bothobandobcommand),src/vimrc/loader.ts(removed duplicateobcommandregistration and unusedexecuteCommandByIdhelper)
- Plugin:
Documentation
docs/configuration/settings.md: added Mobile section withenableOnMobilesetting; addedshowConfigNotificationstoggle to Vimrc & key bindings tabledocs/getting-started/installation.md: added Mobile section with enable instructionsKNOWN_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.tscovering: 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,dismissNoticesadded totest/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 viawindow.requireinstead 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/readLuaFileexternal path fallback),src/vimrc/loader.ts(fileExists/readVimrcFileexternal path fallback),src/settings.ts(updated descriptions)
- Plugin:
- 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 viapickerLeaderMappingssetting, 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:
pickerboolean (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
addCommandfor 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)
- 10 built-in sources: files (
isEasyMotionActive()guard — exported fromsrc/easymotion/register.tsto prevent picker from opening during EasyMotion label selection- Plugin:
src/easymotion/register.ts
- Plugin:
Changed
:buffers/:lsnow opens fuzzy picker instead of VimInfoModal table (whenpickersetting enabled):marksnow opens fuzzy picker with jump-to-mark action (whenpickersetting enabled):registersnow opens fuzzy picker with paste-at-cursor action (whenpickersetting enabled):ob(no args) now opens commands picker instead of VimInfoModal command list (whenpickersetting enabled):grep(no args) now opens live grep picker instead of showing “Usage” notice (whenpickersetting enabled):grep <query>now opens picker with pre-computed results instead of SuggestModal (whenpickersetting enabled):backlinksnow opens fuzzy picker instead of VimInfoModal table (whenpickersetting enabled)- Bundle size: +17.5KB from uFuzzy dependency (unicode mode)
Documentation
docs/features/ex-commands.md: added picker commands sectiondocs/reference/keybindings.md: added picker ex commands and<leader>f*mappingsdocs/configuration/lua-config.md: addedvim.obsidian.pick()API documentation; added “Shared config across vaults” subsection documenting external path supportdocs/configuration/vimrc.md: added “Shared config across vaults” subsection documenting external path supportdocs/configuration/settings.md: updated custom path descriptions to mention absolute path supportKNOWN_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.setleader bindings appear in which-key — leader-prefixed keymaps registered viavim.keymap.setwith adescoption now automatically appear in the which-key overlay, matchingvim.obsidian.leader.addbehavior. Group labels fromvim.obsidian.whichkey.add()work with bothvim.keymap.setandvim.obsidian.leader.addbindings. Buffer-local keymaps (buffer = 0) are excluded from global which-key. (#27)- Plugin:
src/lua/api.ts(leader prefix auto-detection invim.keymap.set),src/main.ts(consumeluaResult.leaderBindingsin LeaderRegistry)
- Plugin:
- Synthetic
BufEnterfor initial file —BufEnterautocmds now fire for the file already open when the plugin loads, matching Neovim behavior. Previously,BufEnteronly fired on subsequent file opens.- Plugin:
src/lua/autocmd.ts(activate()acceptsinitialFilePath),src/lua/loader.ts(passes current file path)
- Plugin:
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 aruntimeExHandlerthat executes commands immediately viavim.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 inonunload)
- Plugin:
- Function-callback keymaps lost after feature reload —
vim.keymap.setwith function callbacks registered keymaps that were silently destroyed whenreloadFeatures()calledvim.resetKeymap(). String-RHS keymaps survived but function callbacks did not. Fixed by movingapplyLuaMaps()to run afterreloadFeatures()and clearingluaActionNamesinloadLuaConfigForTest().- Plugin:
src/main.ts(applyLuaMapsordering,loadLuaConfigForTestcleanup)
- Plugin:
- 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 formsds{/ds[/ds(/ds<strip spaces)csbBysaBbchain —_surroundTypegating prevents stale replacement leaking across different surround operation typescsba..dot-repeat — search position offset by replacement delimiter width for correct nested pair iterationdsbon 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 yswith line-crossing motions (ysjb,ys2jB) — linewise motions now expand range to full linesySS/VSB/cS/yS/gSnewline indentation — single-line content no longer gets extra 2-space indent, matching nvim-surroundVS(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—deleteSurroundPairspace/cursor,findSurroundingFunction, count loops, linewise/block visual handling,_surroundTypedot-repeat isolation - Fork:
src/types.ts—_surroundTypefield onInputStateInterface
Documentation
docs/configuration/lua-config.md: added leader key subsection withvim.g.mapleaderexamples and ordering warning; added tip callout comparingvim.cmd()vsvim.obsidian.leader.add()for leader bindingsdocs/configuration/which-key.md: added “Automatic labels from vim.keymap.set” section documentingdescoption integration with which-key and group label composition withwk.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,getPluginSettingmoved totest/helpers.tsfrom local definitions
[0.39.0] - 2026-07-06
Added
vim.ob.*API expansion (47 new functions across 4 sub-namespaces) — thevim.obsidian/vim.obLua 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’sMetadataCachevim.ob.meta.frontmatter(path?)— returns YAML frontmatter as a Lua table, or nilvim.ob.meta.tags(path?)— returns combined body + frontmatter tags asstring[]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 asstring[]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 asstring[]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
pathis 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)orvim.ob.fs.write(path, content),vim.ob.fs.append(content)orvim.ob.fs.append(path, content) - Management:
vim.ob.fs.rename(new_path)orvim.ob.fs.rename(path, new_path),vim.ob.fs.move(dest)orvim.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) renameusesfileManager.renameFile()which updates backlinks;trashusesfileManager.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
- Read:
vim.ob.ui.*sub-namespace (4 UI control functions) — control Obsidian UI from Luavim.ob.ui.sidebar(side, state?)— toggle/open/close sidebar ("left"/"right", optional"open"/"close"/"toggle")vim.ob.ui.command_palette()— open command palettevim.ob.ui.quickswitch()— open quick switchervim.ob.ui.notice(msg)— alias forvim.notify(convenience for staying invim.obnamespace)- Plugin:
src/lua/obsidian-api.ts
vim.obeditor state and convenience functions — cursor, selection, mode, and notification accessvim.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 nilvim.ob.mode()— alias forvim.fn.mode()(convenience)vim.ob.notice(msg)— alias forvim.notify(convenience)- Plugin:
src/lua/obsidian-api.ts,src/lua/api.ts,src/lua/loader.ts
- Leaf introspection (Tier 1):
- 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}inev.dataLeafLeave— fires when a leaf loses focus (immediate, beforeLeafEnter)FileType— fires afterBufEnterwithev.matchset 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,onActiveLeafChangeextension),src/main.ts(leaf info passthrough)
workspaceNavViewTypessetting — comma-separated list of view types where scroll and count keys are intercepted. Defaults tomarkdown,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 = "..."orvim.opt.workspacenavviewtypes = {"markdown", "graph", "pdf"})- Plugin:
src/settings.ts,src/vim/options.ts,src/vimrc/loader.ts
- Plugin:
vim.opttable (array) support for string options — string-type options can now be set using Lua tables:vim.opt.workspacenavviewtypes = {"markdown", "graph", "pdf"}is equivalent tovim.opt.workspacenavviewtypes = "markdown,graph,pdf". Elements are joined with commas. Applies to all string-type options.- Plugin:
src/lua/api.ts(vim.opt.__newindextable handling)
- Plugin:
Fixed
- Surround opening bracket semantics (
ds(/ds[/ds{/cs({) —findSurroundingBracketsreceived 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 fixescs({,ds(on nested/multiline content, andds<. (#41)- Fork:
src/vim.js—findSurroundingPairbracket parameter ordering
- Fork:
- Surround cursor position after
ys/yss/visualS—addSurroundToRangeplaced the cursor atfrom.ch + pair.open.length(after the opening delimiter). nvim-surround places it atfrom.ch(on the opening delimiter). Fixed by removing+ pair.open.length. The_surroundSelOffset.chDeltaused for dot-repeat now addspair.open.lengthat recording time to compensate, preserving correct visual surround replay ranges. (#41)- Fork:
src/vim.js—addSurroundToRangecursor,surroundVisualoffset recording
- Fork:
- Visual-block cursor displaced rightward at end-of-line — in visual-block mode (
<C-v>), selecting to the end of a line (via$orlto EoL) caused the block cursor to render one position past the last visible character. ThemeasureCursor()function in the fork’sblock-cursor.tshad a guard (!vim.visualBlock) that prevented the EOL step-back for visual-block mode. This guard was originally correct whenmakeCmSelectionproducedtoCh + 1without 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.visualBlockexclusion. (#41)- Fork:
src/block-cursor.ts—measureCursor()EOL adjustment guard
- Fork:
- Visual-block
Askips 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 apadShortLinesparameter toselectForInsertin the fork —Apads,Istill skips (matching Neovim). (#41)- Fork:
src/vim.js—selectForInsert()padding,enterInsertModepasses flag forendOfSelectedArea
- Fork:
- Visual charwise
rreplaces one fewer character across line boundary — thereplaceaction in the fork usedcurEnd = selEnd(the inclusive head position) for charwise visual mode, butcm.getRange()treats the end as exclusive. This causedr <Space>across a line boundary to replace one fewer character than the visual selection covered. Fixed by usingselEnd.ch + 1for the exclusive end. (#41)- Fork:
src/vim.js—actions.replacecharwise visual branch
- Fork:
set insertmodeescape=jkleavesjin buffer after escaping insert mode — theInsertEscapeHandlersentvim.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). SincehandleKeyis 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 thehandleKey('<BS>')loop with a directadapter.replaceRange()call that deletes exactlyescapeSeq.length - 1characters before the cursor (the last key in the sequence is already intercepted bypreventDefaultand never enters the document). The nativeimap jk <Esc>mapping (via vimrc orvim.map()) was unaffected — codemirror-vim’schangeQueuecleanup handles that path correctly.- Plugin:
src/vim/insert-escape.ts(onKeyDownmethod)
- Plugin:
scrolloffvalues above ~30 pin view at bottom of document — highscrolloffvalues (e.g.,set scrolloff=999to center the cursor) caused the viewport to pin at the top or bottom instead of centering. The scroll margin was passed to CodeMirror’sEditorView.scrollMarginsunclamped, producing a target rect taller than the viewport. CM6’sscrollRectIntoViewresolved 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 ofscrolloffto(window_height - 1) / 2. (#48)- Plugin:
src/vim/scrolloff.ts—createScrolloffExtension()viewport-relative clamp
- Plugin:
- 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-gateonKeydownrewrite,GLOBAL_NAV_VIEW_TYPESwhitelist,shouldInterceptContent/shouldInterceptStructuralmethods)
- Plugin:
Changed
minAppVersionbumped from 1.4.10 to 1.6.6 — required forVault.getAllFolders()used byvim.ob.fs.folders()vim.obsidian.*namespace extracted to dedicated module — the Obsidian-specific Lua API is now insrc/lua/obsidian-api.ts(extracted fromapi.ts), following the pattern offn.ts,stdlib.ts,timers.ts,highlight.ts. No behavioral change.api.tsshrinks by ~504 lines.
Documentation
docs/configuration/lua-config.md: addedvim.ob.meta.*(9 functions),vim.ob.fs.*(11 functions),vim.ob.ui.*(4 functions), editor state functions (5 functions) with API tables and examplesKNOWN_LIMITATIONS.md: added “Workspace navigation in plugin views” section documenting three-gate interception and thegg-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-blockApadding and visualroff-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/visualSwith 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):.after2dw,dd,3i,3o,cw,R,2dl,d2w,g~2w,V>,3J,3I, visual block~,oselect-mode-extended(6 cases):gh/gHenter select, type replaces,<BS>deletes,<Esc>exits,<C-g>toggles visual↔selectex-sort(6 cases)::sort,:sort!,:sort i,:sort u,:sort n,:2,3sortex-global(3 cases)::g/pattern/d,:v/pattern/d,:g/a/s/a/x/upstream-gaps(7 cases):dipparagraph, backward blockA, blockAshort-line padding, visualrcross-line, block↔char/line mode switch, macro replay- Test infrastructure:
SuiteDefinition.nvimSetupfield 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: addedWorkspace navigation view typesto Vim features tabledocs/configuration/vimrc.md: addedworkspacenavviewtypes(wnvtalias) to string options tabledocs/configuration/lua-config.md: added 17 newvim.ob.*functions, 3 new autocmd events (LeafEnter,LeafLeave,FileType),workspacenavviewtypesoptiondocs/features/workspace-navigation.md: added “Plugin view compatibility” section with key passthrough table and whitelist customizationdocs/guides/ecosystem-compatibility.md: added “Plugin leaf key passthrough” sectiondocs/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 fullys/ds/cssupport including multi-character delimiters (#36)vim.obsidian.surround.set("l", { left = "[[", right = "]]" })— register a custom pairvim.obsidian.surround.del("l")— remove a custom pairvim.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:
customSurroundPairsregistry,findSurroundingMultiChar()algorithm for multi-char delimiter matching,openWidth/closeWidthsupport indeleteSurroundPair/changeSurroundPair - Plugin:
src/lua/api.ts(vim.obsidian.surroundsub-table),src/vimrc/parser.ts+src/vimrc/loader.ts(surroundmap/surroundunmapcommands),src/main.ts(applyLuaSurroundPairslifecycle)
vim.obsidian.cursor.set()— structured cursor shape configuration — set per-mode cursor shapes via a Lua table instead of theguicursorformat stringvim.obsidian.cursor.set({ normal = "block", insert = "bar", operator_pending = "underline" })— partial tables allowed- Valid shapes:
block,bar,underline,hollow - Equivalent to
vim.opt.guicursorbut uses a table API - Plugin:
src/lua/api.ts(onCursorConfigcallback,vim.obsidian.cursorsub-table)
vim.obsidian.modeprompt.set()— batch mode prompt configuration — set status bar mode text for multiple modes in a single callvim.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(onModePromptConfigcallback,vim.obsidian.modepromptsub-table)
vim.obsidian.leader.set()— leader binding convenience API — bind leader key sequences to Obsidian commands with automatic:obprefix, leader key prepend, and which-key label registrationvim.obsidian.leader.set("e", "file-explorer:reveal-active-file", { desc = "Reveal" })— single bindingvim.obsidian.leader.add({ { "ff", "switcher:open", desc = "Find file" } })— batch registrationdescoption auto-registers a which-key command label- For general-purpose keymaps or Lua callbacks, use
vim.keymap.setinstead - Plugin:
src/lua/api.ts(onLeaderBinding/onLeaderBindingDelcallbacks,vim.obsidian.leadersub-table)
Fixed
vim.g.mode_prompt_*read returns nil for settings-UI-set values — thegetModePromptcallback was defined in theVimApiCallbacksinterface but not wired up inloader.ts. Readingvim.g.mode_prompt_normalreturned nil unless the value was also set viavim.gin the same init.lua session. Fixed by implementing the callback in the loader.- Plugin:
src/lua/loader.ts(getModePromptcallback)
- Plugin:
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-referencesKNOWN_LIMITATIONS.md: updated Lua supported APIs list to includevim.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.nvimwk.add()syntax (#27)vim.obsidian.whichkey.add({ { "<leader>f", group = "Find" }, { "<leader>w", desc = "Save" } })— each entry usesgroupfor prefix labels ordescfor individual binding labels- Per-entry
contextfield:"editor"(default) or"global"for non-editor which-key overlay modefield accepted but reserved for future mode-scoped label support- Entries without a key string or without
group/descare 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_lentype)
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.vimrcand.obsidian.init.luafiles 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-errorclasses)
- Vimrc chain (8 candidates):
Documentation
docs/configuration/vimrc.md: file location section rewritten with full fallback chain tabledocs/configuration/lua-config.md: file location section rewritten with full fallback chain table; addedvim.obsidian.whichkey.add()to API summary table and Obsidian namespace section withwk.add()exampledocs/configuration/settings.md: custom path setting descriptions updated with fallback chain listsdocs/configuration/which-key.md: added “Batch labels (add())” section with Neovim-stylewk.add()syntax,local wkshorthand tip, and reservedmodefield calloutdocs/guides/migrating-from-vimrc-support.md: custom vimrc path section updated with fallback chainKNOWN_LIMITATIONS.md: updated supported Lua APIs list to includevim.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 APIvim.obsidian.keymap.set(lhs, rhs, opts?)— create a global keymap with:obcommand <id>or:<ex-command>as RHSvim.obsidian.keymap.del(lhs)— remove a global keymapdescoption auto-creates a label in the global which-key popup- Lua global keymaps override vimrc
gmapon conflict (last-write-wins) - Survives settings changes and feature reloads via
luaGlobalMapspersistence arrays - Plugin:
src/lua/api.ts(LuaGlobalKeymaptype,onGlobalKeymap/onGlobalKeymapDelcallbacks)
vim.obsidian.whichkey— which-key labels from Lua — set group and command labels for the which-key popupvim.obsidian.whichkey.set_group(key, label, opts?)— name a which-key group by prefixvim.obsidian.whichkey.set_label(key, label, opts?)— label an individual which-key bindingcontextoption defaults to"editor"; use{ context = "global" }for non-editor which-key overlay- Previously only available via vimrc
whichkeygroup/whichkeylabeland Settings UI - Plugin:
src/lua/api.ts(onWhichKeyGroupLabel/onWhichKeyCommandLabelcallbacks)
vim.opt.guicursor— cursor shapes from Lua — set per-mode cursor shapes withoutvim.cmdpassthroughvim.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 configsvim.json.encode(value)/vim.json.decode(str)— JSON serialization bridged to JavaScript’sJSON.stringify/JSON.parse- Plugin:
src/lua/stdlib.ts
- Table utilities (12):
- Async primitives (
vim.schedule,vim.defer_fn,vim.uvtimers) — Neovim-compatible async APIs for deferred execution and timer managementvim.schedule(fn)— defer function to next event loop iteration (useful for breaking recursive autocmd loops)vim.schedule_wrap(fn)— returns a function that wrapsfnwithvim.schedule, passing all argumentsvim.defer_fn(fn, timeout)— defer function bytimeoutmilliseconds, returns cancellable handle withstop()/close()/is_closing()vim.uv.new_timer()— create timer withstart(delay, repeat, callback),stop(),close(),is_closing(),is_active()vim.uv.hrtime()— high-resolution time in nanosecondsvim.uv.now()— current time in millisecondsvim.loopalias forvim.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 switchvim.keymap.set("n", "gd", handler, { buffer = 0 })— keymap active only in the current filevim.api.nvim_buf_set_keymap(0, mode, lhs, rhs, opts)/nvim_buf_del_keymap(0, mode, lhs)— low-level buffer keymap APIs- Combined with
BufEnterautocmd 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 callbacksvim.api.nvim_buf_get_lines(0, start, end, strict_indexing)— 0-based, end-exclusive,-1for EOFvim.api.nvim_buf_set_lines(0, start, end, strict_indexing, replacement)— empty table deletes linesvim.api.nvim_get_current_buf()— returns0(current buffer)vim.api.nvim_buf_get_name(0)— vault-relative file pathvim.api.nvim_buf_line_count(0)— total line countstrict_indexing = trueerrors on out-of-bounds;falseclamps silently
- 4 new autocmd events —
CursorMoved,CursorHold,BufWritePre,BufWritePost(total: 12 events)CursorMoved— fires after cursor moves (throttled viavim-command-doneevent)CursorHold— fires after cursor is idle forupdatetimems (default 4000, configurable viavim.opt.updatetime)BufWritePre/BufWritePost— fire before/after:w,:wq,:x,:wall,:updatewith vault-relative glob pattern supportupdatetimeoption added toKNOWN_SET_OPTIONSfor vimrc andvim.optconfiguration
vim.obsidiannamespace (vim.obalias) — Obsidian-specific APIs that don’t exist in Neovimvim.obsidian.vault_name(),vim.obsidian.app_version(),vim.obsidian.plugin_version()vim.obsidian.run_command(id)— execute any Obsidian command by IDvim.obsidian.list_commands()— table of{id, name}for all available commandsvim.obsidian.open_file(path)— open a vault filevim.obsidian.current_file()— table{path, name, extension, basename}or nilvim.obsidian.vault_path()— vault absolute path (desktop only)
- Sandboxed
vim.env— environment variable proxy with curated values and user-defined storagevim.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 inprocess.env - Unknown keys return nil
vim.api.nvim_set_hl— highlight group → CSS bridge — customize plugin styling from Lua using Neovim’s highlight APIvim.api.nvim_set_hl(0, "EasyMotionTarget", { fg = "#ff5555", bold = true })— change EasyMotion label colorsvim.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-GroupNameCSS 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 attrsvim.api.nvim_create_namespace(name)— returns0(only global namespace supported)- Plugin:
src/lua/highlight.ts(HighlightManager)
- Enhanced
vim.notifywith log levels —vim.notify(msg, level)routes messages by severityvim.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 onlyvim.notify_once(msg, level)— deduplicates by message content
Fixed
- Space-leader global keymaps not matching keyboard input —
replaceLeaderKeyconverted<leader>to raw" "(space character), butnormalizeKeyEventinGlobalKeyHandlerconverted spacebar to"<Space>". The key sequences never matched inGlobalMappingRegistry.resolve(). Fixed by addingnormalizeKeyString()to convert raw special characters to angle-bracket notation (" "→"<Space>") before storing keys in the registry. Affects both vimrcgmapand Luavim.obsidian.keymap.setwith space leader.- Plugin:
src/workspace/global-mapping-registry.ts(normalizeKeyString),src/main.ts(applyGlobalMaps,rebuildGlobalWhichKey)
- Plugin:
vim.g.mode_prompt_*reads returned nil after write — the__newindexhandler for mode_prompt keys calledonSettingOverridebut did not store the value in theglobalsMap. The__indexhandler’s fallback toglobals.get(key)returnedundefined. Fixed by also storing inglobalson write.- Plugin:
src/lua/api.ts(vim.g__newindexhandler)
- Plugin:
Changed
vim.apiexpanded 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_keymapadded alongside existing autocmd/augroup/user command functions- Autocmd events expanded from 8 to 12 — added
CursorMoved,CursorHold,BufWritePre,BufWritePost vim.obsidiannamespace expanded — addedkeymapandwhichkeysub-namespaces for global keymaps and which-key labels.vim.obalias 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-eventev.datafields), 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.guicursoroption; fixedbufferoption row (was “Not supported”, now correctly documentsbuffer = 0/true); fixedos/debuglibrary availability claims (not loaded by plugin); addedvim.stricmp,vim.env.MYVIMRC,underdouble/underdotted/underdashedhighlight attributes, TextYankPostregnamefield, highlight group case-sensitivity callout, buffer-local keymap accumulation warning, underline style limitation calloutdocs/configuration/which-key.md: added Lua examples for group labels (vim.obsidian.whichkey.set_group) and global which-key labelsdocs/configuration/cursor-shapes.md: addedvim.opt.guicursorLua section, removed “not supported” workaround notedocs/configuration/status-bar.md: expanded Lua mode prompt examples to all 11 modesdocs/features/ex-commands.md: added Lua example for custom commands vianvim_create_user_commandKNOWN_LIMITATIONS.md: updated supported APIs list (addedvim.obsidian.keymap,vim.obsidian.whichkey,vim.opt.guicursor), correctedos/debuglibrary availability (not loaded by plugin sandbox)AGENTS.md: clarified fengari fork vs plugin library loading distinction (fork keepsos/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(Luaiolibrary),loadlib.js(Luapackage/require()system), Node.js branches fromloslib.js/ldblib.js/lauxlib.js/lbaselib.js/luaconf.js - Removed npm dependencies:
readline-sync,tmp(keptsprintf-jsforstring.format) - Retained browser-safe
oslibrary functions:os.date,os.time,os.difftime,os.clock,os.setlocale - Retained
debuglibrary (minusdebug.debug()interactive REPL):debug.traceback,debug.getinfo,debug.sethook, etc. - Fixed crash-on-mobile bug: upstream’s unconditional
process.env.FENGARICONFaccess at module load time throwsReferenceErroron 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 usesconsole.log(previously usedprocess.stdout.writein Electron)luaL_loadfilexstubbed to return error (plugin already disabledloadfile/dofileat Lua level)- Dependency pattern matches codemirror-vim fork:
"fengari": "https://github.com/saberzero1/fengari.git"inpackage.json
- Removed from fork:
[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 familiarvim.keymap.set/vim.optsyntax. 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 sameKNOWN_SET_OPTIONSmap as vimrcsetcommands)vim.g.mapleader/vim.g.<name>— set leader key and user variablesvim.keymap.set(mode, lhs, rhs, opts)— key mappings with string or function RHS,descfor which-key labels,noremap/remapcontrol, multi-mode supportvim.keymap.del(mode, lhs)— remove mappingsvim.cmd(string)— execute ex commands (deferred until first editor focus)vim.vault_name()— returns the current vault name for per-vault conditional configvim.notify(msg)— show an Obsidian notification from Luaprint(...)— outputs to developer console- Sandbox: 6 defense layers — selective library loading (no
io/os/debug/package), dangerous globals stripped (load/dofile/loadfile), nofengari-interop, instruction-count timeout vialua_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:
configModedropdown (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-keymapssuite), 17 e2e integration tests, 4 known deviations registered
vim.fn.*Neovim function subset — 27 functions from Neovim’svim.fnnamespace, 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 blockedvim.fn.glob(pattern)— vault-scoped file matchingvim.fn.line('.')/vim.fn.col('.')/vim.fn.getline('.')— context-aware: return cursor position in function callbacks, return 0 at config-load timevim.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,__indexdispatch),src/lua/strftime.ts(pure strftime utility)
- Config/detection (13):
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 withopts.args vim.apichanged from error stub to partial namespace: unsupportedvim.api.*functions give a helpful error listingnvim_create_user_commandas available- Registered commands are immediately usable from the
:ex command line
- String RHS:
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_autocmdsfor 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-yanksignal added to yank/delete/change operators invim.js - 16 unit tests, 2 e2e tests
- Events:
- 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:watchscripts- Obsidian module mocked via
test/unit/__mocks__/obsidian.ts - CI:
.github/workflows/lint.ymlnow 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, allvim.optoptions,vim.fn.*function tables (has features, expand modifiers, fnamemodify modifiers, exists expressions), mapping examples, conditional config examples, loading order, unsupported API documentationdocs/configuration/settings.md: updated withconfigModedropdown replacing old toggles, added Lua column to all settings tablesdocs/configuration/index.md: reordered — Lua configuration presented as primary method, vimrc as alternativedocs/configuration/vimrc.md: added tip pointing to Lua configuration for advanced use casesdocs/configuration/which-key.md: added Luadescoption integration for which-key labelsdocs/configuration/cursor-shapes.md: addedvim.cmdworkaround note for guicursordocs/configuration/status-bar.md: added Lua equivalents for status bar settingsdocs/features/quality-of-life.md: added Lua examples alongside vimrcdocs/features/workspace-navigation.md: added Lua examples alongside vimrcdocs/getting-started/quickstart.md: reordered — Lua shown as recommended configuration pathdocs/reference/known-limitations.md: Lua configuration section with supported/unsupported APIs, hybrid loading, vim.fn subset, bundle sizeKNOWN_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
handleKeyduring the bubble phase. However, Obsidian’sKeymapregisters its keydown listener onwindowin 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 viaexecuteCommandById(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, andeditor:indent-listall affected only 1 line regardless of invocation method. Fixed by wrappingapp.commands.executeCommandviaaround()to temporarily expand the CM6 selection to the full linewise range fromvim.selbefore any Obsidian command executes, then restoring cursor-only after. Covers all invocation paths: hotkeys, command palette, toolbar, and programmaticexecuteCommandById. (#41)- Plugin:
src/vim/visual-line-command-fix.ts—installVisualLineCommandFix()wrapsapp.commands.executeCommandusing the existingaround()utility (safe for multi-plugin stacking); installed inonload(), cleaned up inonunload() - Spike test:
test/specs/spikes/spike23-visual-line-hotkey-commands.e2e.ts— 10 tests verifying direct command, hotkey, and selection state behavior
- Plugin:
[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.ghenters charwise,gHlinewise,g<C-h>blockwise.<C-g>toggles between visual and select mode.<BS>deletes the selection. Matches Neovim behavior. (#45)- Fork:
enterSelectMode,toggleSelectMode,preventReselectactions invim.js;selectModeflag on vim state;'select'context for keymap dispatch with visual fallback;gvpreserves and restores select mode vialastSelection - Fork:
:smap,:snoremap,:sunmap,:smapclearex commands for select-mode-specific mappings - Fork:
selectmodeoption (set selectmode=cmdmakesv/V/<C-v>enter select mode);keymodeloption (accepted, shifted cursor key behavior deferred) - Plugin: status bar shows
SELECT,data-vim-mode="select", powerline CSS with::aftertriangle, Style Settings entries - 16 fork browser tests, 5 Neovim golden test cases, 3 e2e tests
- Fork:
- 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:
virtualReplaceCharandvirtualReplaceBackspaceadapter methods incm_adapter.ts;virtualReplaceflag andreplaceStackon 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
- Fork:
- Visual Line / Visual Block mode indicators — status bar now distinguishes
V-LINEandV-BLOCKfromVISUAL. Uses the fork’s existingsubModeevent 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
- Plugin: mode-tracker maps
- Command-line and Search mode indicators — status bar shows
COMMANDwhen:prompt is open andSEARCHwhen/or?prompt is open. Detects dialog type via DOM text node inspection of the fork’s"dialog"event. (#45)- Plugin:
dialogHandlerin mode-tracker withpreDialogModetracking 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→/→Esccycling)
- Plugin:
- Insert-Normal mode indicator — status bar shows the configured insert-normal prompt (default
NORMAL) when<C-o>is pressed in insert mode, then returns toINSERTafter one command. (#45)- Plugin: mode-tracker detects
subMode.startsWith('ctrl-o')→insertNormal;data-vim-mode="insert-normal"; powerline CSS - 2 e2e tests
- Plugin: mode-tracker detects
- 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:
ModePromptsinterface expanded; settings UI entries for all modes; vimrcVIMRC_MODE_MAPwith snake_case → camelCase mapping;RELOAD_KEYSupdated
- Plugin:
- 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>(aliaswkd) in vimrc. Range 0–2000ms, default 500ms. Set to0for 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: addedwhichKeyDelay: numbertoVimMotionsSettings(default 500), added toRELOAD_KEYS, added number input control in “Which-key hints” groupsrc/vimrc/loader.ts: addedwhichkeydelay/wkdtoKNOWN_SET_OPTIONS(number, 0–2000)src/ui/which-key.ts: replaced hardcodedSHOW_DELAYwith configurableshowDelayconstructor parameter;onKeyPressGeneralupdates overlay immediately when already visible instead of restarting delay; extractedshowCompletionsIfPartial()helpersrc/ui/global-which-key.ts: same pattern — configurable delay, instant updates when overlay already visiblesrc/main.ts: passessettings.whichKeyDelayto bothWhichKeyOverlayandGlobalWhichKeyOverlayconstructors
Fixed
<C-o>in replace mode returns to insert instead of replace —oneNormalCommandnow saves the pre-Ctrl-O mode state and returns to the correct mode (insert, replace, or virtual replace) after the single normal command. Uses_suppressModeSignalto prevent a spurious{mode:"normal"}event, emitting{mode:"normal", subMode:"ctrl-o"|"ctrl-o-replace"|"ctrl-o-vreplace"}instead. (#45)- Fork:
insertModeReturnArgson vim state;_suppressModeSignalflag inexitInsertMode - 5 fork browser tests, 2 Neovim golden test cases, 2 e2e tests
- Fork:
Rmode<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:
handleReplaceModeInputpushes original chars toreplaceStackbefore overwriting; BS pops and restores with explicitsetCursorfor correct positioning - 4 fork browser tests
- Fork:
- Replace/vreplace character I/O only works through DOM events — unified replace mode character handling from
index.ts(DOM-only path) intovim.js(handleReplaceModeInput).Vim.handleKeyis now authoritative for all replace-mode operations — programmatic dispatch, macro replay, and dot-repeat work correctly through both paths. (#45)- Fork:
handleReplaceModeInputinvim.jscalled fromhandleKeyInsertModematch.type == 'none'branch;virtualReplaceChar/virtualReplaceBackspaceadapter methods; removed overwrite block and helpers fromindex.ts - 7 fork browser tests (overwrite, BS restore, dot-repeat, macro replay, Ctrl-H)
- Fork:
Documentation
docs/configuration/status-bar.md: lists all 11 mode indicators, alldata-vim-modeattribute values, all CSS variables, all vimrc directives, fork mode requirement calloutdocs/configuration/settings.md: all 11 mode prompt settings with vimrc equivalentsdocs/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) sectionsdocs/reference/known-limitations.md: select mode and virtual replace mode limitationsKNOWN_LIMITATIONS.md:selectmode=mouseCM6 limitation,selectmode=key/keymodel=startseldeferred, East Asian Width,gRnewline behaviorDIFFERENCES.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:
handleKeyinindex.tsnow expands CM6 selection on unhandled keys during visual-line mode and restores cursor-only viaPromise.resolve().then()
- Fork:
[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>fto open the quick switcher in non-editor viewsgnoremap <leader>s :sidebar left— functionally identical togmap(accepted for vim syntax familiarity)gunmap H— remove the defaultH → previous tabbinding (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;
gunmapremoves any binding (user or default) - Count prefix support:
5jscrolls 5 lines,3gtgoes 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.tsfrom 770-line hardcoded state machine to 255-line table-driven dispatch viaGlobalMappingRegistry - 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>showsh/j/k/l/v/s/c/q/owindow commands. Controlled by the existingwhichKeyModesetting (off/leader/all).- New file:
src/ui/global-which-key.ts—GlobalWhichKeyOverlayclass, shares CSS with editor which-key - Reuses
vim-motions-which-keyCSS classes fromstyles.css(no CSS changes needed) - Popout window support via
Documentparameter tracking - Dismiss on sequence completion, timeout, or focus change to editor
- New file:
- Global which-key labels (
gwhichkeylabel/gwhichkeygroup) — label global bindings for the non-editor which-key overlay, independent from editor which-key labelsgwhichkeylabel <leader>f Open file— shows “Open file” instead of the raw command IDgwhichkeygroup <leader> +leader— groups<leader>*bindings under a named prefix
:gmapex command — lists all active global bindings with source (default/user) in a modal. Available in both editor and non-editor:command contexts.executeGlobalExCommandhelper — exported fromglobal-ex-command.tsfor programmatic ex command dispatch without opening the modal UI
Documentation
docs/configuration/vimrc.md: addedgmap/gnoremap/gunmap/gwhichkeylabel/gwhichkeygroupto supported commands table, added “Global key mappings” section with full syntax and examplesdocs/features/workspace-navigation.md: added “Customizing global bindings” sectiondocs/configuration/which-key.md: updated modes to note non-editor overlay support, added “Global (non-editor) labels” sectiondocs/reference/keybindings.md: added:gmapex 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 onain- a) and moving down to a line with a checkbox (- [ ] d), the cursor positionsel.head.chwas preserved from the starting column. In Live Preview,[ ]is replaced by a checkbox widget viaDecoration.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:
updateCmSelectioninvim.jsnow usescm.setCursor(sel.head.line, 0)instead ofcm.setCursor(sel.head.line, sel.head.ch) - 2 new Neovim golden comparison test cases:
Vfrom mid-column +j+dwith checkbox content,Vfrom mid-column +2j+ycursor at col 0
- Fork:
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 calledupdateCmSelection(cm)outside of acm.operation()context. With cursor-only CM6 selection,handleExternalSelectiondetectedvisualMode && !somethingSelected()and exited visual mode. Fixed by wrapping the callback’supdateCmSelectioncall incm.operation()withisVimOp = true, matching the protection used by all other vim operation entry points. (#41)- Fork: async motion visual mode branch in
vim.jsnow wrapsupdateCmSelectionincm.operation()withisVimOp = true - Test:
easymotion-visual.e2e.tsupdated to verify visual-line easymotion via register content (yank +getRegisterContent) instead ofgetSelection(), which returns empty with cursor-only CM6 selection
- Fork: async motion visual mode branch in
- 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::selectionCSS simultaneously, causing a visible double-highlight. Fixed by adding a.cm-vimVisualLineclass to the editor scrollDOM when in visual-line mode and extending the::selectiontransparency 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/kon 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:updateCmSelectionset a spanning CM6EditorSelectionrange across the full line content; Obsidian’s Live Preview detects selection overlap withDecoration.replaceranges and reveals them. Fixed by setting a cursor-only CM6 selection (atsel.headposition) in visual-line mode — thelinewiseVisualHighlightViewPlugin already provides the visual highlight independently fromvim.sel, and operators (y/d/c) recompute their own selection at dispatch time. (#41)- Fork:
updateCmSelectioninvim.jsnow setscm.setCursor(sel.head)instead of a spanning range whenvim.visualLineis true - Fork:
joinLinesaction invim.jsnow reads fromvim.selinstead ofcm.getCursor('anchor'/'head')in visual mode, fixingV+Jregression from cursor-only selection - Fork:
replaceaction invim.jsnow reads fromvim.selinstead ofcm.getCursor('start'/'end')in visual mode, with line boundary expansion for visual-line; removed unusedselectionsvariable - Fork:
index.tsadds Ctrl+C special-case that copies linewise text fromvim.selwhensomethingSelected()returns false in visual-line mode - Fork:
index.tsadds.cm-vimVisualLineclass toggle inupdateClass() - Fork:
block-cursor.tsextends::selectionsuppression CSS selector to include.cm-vimVisualLine - Plugin:
styles.cssoverrides.cm-vim-linewise-selectionwithvar(--text-selection)for theme alignment (already present from 0.27.0) - 6 new Neovim golden comparison test cases:
V+j+ycursor position,V+2j+dmulti-line delete,Vkupward selection,Vjkround-trip,v→Vtransition,V→vtransition - 7 new e2e tests: visual-line yank with markup content, multi-line yank register verification,
gvafter visual-line yank,v→VandV→vmode transitions
- Fork:
[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 thegetSettingDefinitions()declarative API. On Obsidian 1.13+, users could not see or configure the custom vimrc path in settings. Added thevimrcPathtext control to the “Vimrc & key bindings” group ingetSettingDefinitions(), withaliasesfor settings search discoverability and adisabledpredicate gated onenableVimrc. (#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.
factivates (click/focus),Fopens in a new pane,yfyanks the target’s URL or text to clipboard,dfcloses the target tab or pane. Count prefix works:3factivates 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 withHintTargettype classification (link/pane/tab/button/input/generic), four action functions (hintActivate/hintOpenNew/hintYank/hintClose),createHintActions()factory, count support viarequestAnimationFramerecursion, modifier-based action upgrade,el.isConnectedvalidation, clipboard fallbacksrc/workspace/global-key-handler.ts: addedY_PENDING/D_PENDINGstates toSeqStateenum,hintActionsconstructor parameter,f/F/y/ddispatch in IDLE and COUNT states,handleYPending/handleDPendinghandlers,chordText()updatessrc/main.ts:registerHintMode()→registerHintActions(),hintModeAction→hintActionsfield, 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-containeris detected in the DOM viaisModalOpen(). Hint actions (f/F/yf/df) still work in modals — they use a separateshouldInterceptHints()gate that does not check for modals. - Hint mode labels re-trigger instead of selecting label characters — pressing
fto activate hint mode, then typing a label character that is alsof, would re-trigger hint mode via GlobalKeyHandler instead of being captured by the label selection handler. Fixed by adding anisHintModeActive()flag (exported fromhint-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) requiredpointerdown/pointerupevents beforeclickto 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 theis-measuringclass 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
ffor 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 achangeevent, giving immediate feedback instead of requiring manual Arrow key interaction. - Broadened form control selectors —
STANDARD_SELECTORSnow includesinput:not([type="hidden"]):not([disabled]),textarea:not([disabled]), andselect: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-containerto.checkbox-containerto match toggles rendered by Obsidian 1.13+‘s declarative settings API outside the traditional.setting-item-controlparent.
Changed
- Scrolloff cap raised from 20 to 9999 — the
scrolloffsetting now accepts values up to 9999 (previously capped at 20), enabling the standard Vim pattern ofset scrolloff=999to 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), vimrcset scrolloff=N/set so=N, and the vimdefineOptioncallback. The underlying CSSscrollMarginsimplementation was already uncapped. (#40)src/settings.ts: structured definition changed fromtype: 'slider'totype: 'number'withmax: 9999; manual rendering changed from.addSlider()to.addText()withtype='number',min='0',max='9999', integer clamping, and fallback to default 5 on invalid inputsrc/vimrc/loader.ts:scrolloffandsooption definitions updated frommax: 20tomax: 9999src/vim/options.ts:defineOptioncallback validation updated fromn <= 20ton <= 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 handlingKNOWN_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 patternREADME.md: updated hint mode section with vimium-style actions, non-editor key table, and new Obsidian commandsREADME.md: updated workspace keyboard control table with hint action keysREADME.md: updated scrolloff range from 0–20 to 0–9999 in number options table and settings list; updated scrolloff description to mentionset scrolloff=999for centered cursor
[0.26.0] - 2026-07-02
Fixed
- Stale jumpList markers crash vim state on document switch —
gg,G, and other motions withtoJumplist: truethrewRangeError: Invalid position N in document of length Mwhen switching between documents of different lengths (especially with PDF++ plugin). The global jumpList storedMarkerobjects with absolute offsets from the previous (longer) document. WhenjumpList.add()calledcurMark.find()on a stale marker,posFromIndexpassed the old offset todoc.lineAt()without bounds checking, crashing throughprocessMotion→processCommand→ thecm.operation()try-catch, which wiped and re-initialized vim state. Subsequent keystrokes fell through to default CM6 text insertion. (#18)- Fork:
posFromIndexnow clamps offset to[0, doc.length], mirroringindexFromPosbounds checking - Fork:
Marker.find()catches exceptions and returnsnullfor stale markers (all callers already handlenull) - Fork:
Marker.update()catchesRangeErrorfrommapPos()when marker offset exceeds the changeset’s starting document length, settingoffset = null - Plugin:
reloadFeatures()now callsvim.resetKeymap()to matchonload()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)
- Fork:
- 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/&darkCSS variants never activated because Obsidian doesn’t addcm-dark/cm-lightclasses to.cm-editor. Added a CSS override instyles.cssusingvar(--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’sHyperMD-codeblock-bgclass on the same.cm-lineelement. 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 —
makeCmSelectionin the fork’s block mode branch added+1totoChfor inclusive selection without per-line clamping. When$(end-of-line) settoChto the actual line length,toCh + 1pushed the cursor one position past the last character. Fixed by clampingtoChandfromChto each line’s length inside the per-line loop, since each line in a block selection has a different length. The$motion’sInfinityreturn forchis preserved upstream — clamping only happens at the selection-building stage. (#38) - Formatting mark transaction filter corrupts visual selections — the
EditorState.transactionFilterinformatting-mark-fix.tssnapped 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’ssnapRangefunction modified the selection head to a formatting mark boundary, causing the selection to jump or collapse unexpectedly. Fixed by adding arange.emptyguard that skips snapping for non-empty (visual) selections — the formatting mark correction is only needed for normal-mode cursor movement. (#38)- Fork:
makeCmSelectionblock mode now clampstoCh/fromChper-line vialineLength(cm, top + i) - Plugin:
formatting-mark-fix.tsskipssnapRangewhenrange.emptyis false - Plugin:
styles.cssadds.cm-editor .cm-line.cm-vim-linewise-selectionoverride withvar(--text-selection)fallback chain
- Fork:
- Visual block
$delete cursor deviation —<C-v>jj$dleaves cursor atch:1instead of Neovim’sch:0after 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 intest/neovim/deviations.ts.
Documentation
KNOWN_LIMITATIONS.md: updated “Formatting mark cursor correction in Live Preview” section to document the visual mode bypassKNOWN_LIMITATIONS.md: updated “Block visual mode” section test coverage count (13 → 15 golden tests)DIFFERENCES.md(fork): added “Block visual EOL cursor clamping” section documentingmakeCmSelectionper-line clampREADME.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.vimrcbecause the vimrc loader explicitly callsvim.setOption(). Fixed by addingvim.setOption()calls to each vim engine setting’sonChangehandler insrc/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.cssfile 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 triggersparse-style-settingson 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 viasetGlobalChord()onVimModeTracker - 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—GlobalKeyHandlerclass withshouldIntercept(),SequenceStateMachine, scroll target detection - New file:
src/ui/global-ex-command.ts—GlobalExCommandModalextending Obsidian’sSuggestModal src/vim/mode-tracker.ts: addedsetGlobalChord(text)method for non-editor chord displaysrc/workspace/navigation.ts: exportedexecuteCommand()for reuse by global handler- E2E test suite
test/specs/global-nav.e2e.tswith 15 tests covering navigation, scrolling, ex commands, input suppression, sequence timeout, and no-regression
- Navigation:
H/Ltab switching in non-editor views — repurposesH/L(screen top/bottom in editors) for previous/next tab navigation when a non-editor view is focused, matching obsidian-vim-keynav conventionsCtrl-o/Ctrl-ihistory navigation in non-editor views — maps toapp:go-back/app:go-forwardwhen 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*.vimrcfiles in the vault. Leave empty to use the default.obsidian.vimrc. Changing the path triggers a full vimrc reload. (#34)src/settings.ts: addedvimrcPath: stringtoVimMotionsSettingsinterface and defaults, addedvimrcPathtoRELOAD_KEYS, added file-suggest text input below the “Load .obsidian.vimrc” togglesrc/ui/vimrc-file-suggest.ts: new file —VimrcFileSuggestextends Obsidian’sAbstractInputSuggest<TFile>to autocomplete vault files ending in.vimrcsrc/vimrc/loader.ts:getVimrcPath(),loadVimrc(), andresolveLeaderKey()accept optionalcustomPathparametersrc/main.ts: passessettings.vimrcPathto loader functions- E2E test suite
test/specs/vimrc-custom-path.e2e.tswith 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,:xallnow close ALL view types — previously filtered bygetViewType() === 'markdown', leaving PDFs/images/etc. open. Now closes all tabs regardless of view type, matching Neovim behavior. Same change applied tog<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 UIKNOWN_LIMITATIONS.md: updated “Vimrc hot-reload” section to document custom vimrc path behaviorREADME.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-reloadREADME.md: updated powerline status bar description to mention Style Settings support; updated label colors description to mention Style SettingsREADME.md: updated vimrc support section, settings list, and quality of life to document custom vimrc path settingstyles.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.prototypemonkey-patching with a CM6EditorState.transactionFilterthat 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-Vblock visual mode now supportsI(insert at left column),A(append at right column),c(change block), andC(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:
enterInsertModepreserveswasInVisualBlockbeforeexitVisualModeclears the flag - Fork:
selectForInsertskips lines shorter than the block column instead of clipping - Fork:
operators.changeadds avim.visualBlockpath for block change and block change-to-EOL - Fork:
exitInsertModepositions cursor at the block’s left column viablockInsertLeftinstead of the standardch - 1, matching Neovim’s cursor placement after blockA - Fork:
makeCmSelectionblock mode treatsfromCh === toCh(zero-width block) the same asfromCh < toCh, fixingCon zero-width blocks - Fork:
repeatInsertModeChangesusesblockInsertLeftfor cursor placement after dot-repeat instead of hardcoded+1
- Fork:
- Neovim golden comparison tests for block visual: 13 golden test cases in
test/specs/vim-builtin/visual-block-golden.e2e.tscovering 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.tswith 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.tscoveringma/'a,`b,'.,'',`` - Neovim golden comparison tests for macros: 5 golden test cases in
test/specs/vim-builtin/macros-golden.e2e.tscoveringqa/@a,2@a,@@,3@a, insert replay - Expanded register golden tests: 3 new cases (
"Ayyappend,"0pnumbered register,"a/"bindependent) innormal-yank-putsuite - Expanded search/replace golden tests: 2 new cases (
:%sglobal,:2,3srange) inex-commands-builtinsuite - Formatting mark cursor golden tests: 3 new cases (
wthrough**,fpast**,ethrough backticks) innormal-motionssuite
Fixed
- Vimrc
whichkeygroup/whichkeylabelcommands crash on load —defineEx('whichkeygroup', 'wkg', ...)threwError: (Vim.defineEx) "wkg" is not a prefix of "whichkeygroup"becausedefineExrequires the short form to be an actual starting substring of the command name, not an arbitrary abbreviation. Same issue forwhichkeylabel/wkl. Fixed by changing the prefixes to valid substrings:whichkeygandwhichkeyl. User-facing vimrc syntax (whichkeygroup,whichkeylabel) is unchanged. Theset whichkeygrouping/set wkgoption alias (handled by a separateKNOWN_SET_OPTIONSpath) was already correct and unaffected. (#31) - Block visual mode deviations removed — all
CTRL-Vblock visual deviations intest/neovim/deviations.tshave been removed. Block insert/change now matches Neovim output with zero deviations: cursor position afterAexit is correct, short lines are skipped, and zero-width blocks work for all operators. - Golden recording infrastructure —
test/neovim/record-golden.tsnow 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 ing-commands.json(3 mode corrections) andvisual-mode.json(1 mode correction, 1 cursor + mode correction). - Search dispatch in test wrapper —
test/neovim/test-wrapper.tsnow detects/pattern\nand?pattern\nsearch 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)” sectionDIFFERENCES.md(fork): added “Block visual insert (I/A), change (c/C)” section documenting all 6 fork changesREADME.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 imperativedisplay()method continues to work unchanged. NominAppVersionbump 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 triggerreloadFeatures()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
rendercallbacks delegating to the existing imperative rendering methods styles.css: added.vim-motions-hiddenutility 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
isDesktopOnlytofalseinmanifest.json. EasyMotion and hint mode are disabled on mobile viaPlatform.isMobileguards because they depend onactiveDocument/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: addedPlatform.isMobileguards to skip EasyMotion and hint mode registration on mobile (inonload,reloadFeatures, andreregisterLeaderFeatures)eslint.config.mts: added@codemirror/*and@lezer/*toimport/no-nodejs-modulesallow list —eslint-plugin-obsidianmdenables this rule whenisDesktopOnly: false
Fixed
- EasyMotion big-WORD regex crashes on iOS < 16.4 —
BIG_WORD_START_REused 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\Stransitions mid-line. Theobsidianmd/regex-lookbehindlint rule (enabled whenisDesktopOnly: false) caught this. (#30) import/no-nodejs-modulesfalse positives on@codemirror/*imports —eslint-plugin-obsidianmdenables this rule whenisDesktopOnly: falseinmanifest.json. The existingimport/core-modulessetting does not affect this rule’s allow list. Added explicitallowentries for all@codemirror/*and@lezer/*packages to the rule configuration.- Configurable insert mode escape timeout —
set insertmodeescapetimeout=N(aliasimet, range 100–5000ms, default: 1000ms) controls how long the plugin waits between keystrokes when matching theinsertmodeescapesequence (e.g.jk). Matches Neovim’stimeoutlendefault 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 runtimeVim.setOption('insertmodeescapetimeout', 500). (#31) - Vimrc ↔ Settings parity — all plugin settings are now configurable via
.obsidian.vimrcin 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
enableVimrcis 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.
sourcedirective fix: settings andguicursorin sourced vimrc files now propagate correctly (pre-existing bug whereonCursorShapeChangewas not passed to recursiveloadVimrcFilecalls)vimrcLoadingflag fix: the flag is now reset tofalseafter successful vimrc load, enabling runtime:setcommands to trigger immediatereloadFeatures()
- Boolean feature toggles via
- Vimrc
setcommand routing — all knownsetoptions are now handled directly in the vimrc loader via aKNOWN_SET_OPTIONSmapping table, callingonSettingOverridedirectly instead of relying ondefineOptioncallback dispatch throughvim.handleEx. This ensures reliable settings override regardless of codemirror-vim initialization order. Unknown options fall through tohandleExfor forward compatibility. - Spurious
defineOptioncallback prevention —registerVimOptionsnow uses aregisteredflag to preventdefineOptioncallbacks from firing during initial option registration (codemirror-vim callssetOption(name, defaultValue)internally duringdefineOption). Without this guard, every option with a truthy default would spuriously populatevimrcOverridesand triggerreloadFeaturesduring plugin startup. - E2E test suite
test/specs/vimrc-settings.e2e.tswith 11 tests covering boolean/number/string/enum option overrides, mode prompts, which-key labels, override tracking, and combined overrides set insertmodeescape=jknot working (frame-perfect timing required) — theInsertEscapeHandlerlistened tovim-keypressevents, 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 DOMkeydownevents on the editor element, correctly intercepting keystrokes in insert mode. Also fixed theinsertmodeescapevim option not storing its value forgetOption()retrieval (callback returnedundefinedinstead of the stored value). (#31)dknot deleting in operator-pending mode —dk(delete current and previous line) was a no-op becausetableAwareMoveUpwas registered withcontext: 'normal', causing it to be filtered out in operator-pending mode. CM Vim’s keymap search then failed to fall through to the defaultkmotion. Removed the context restriction since the motion already handles operator-pending mode internally via itshasOperatorcheck.- 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 usesDecoration.replace({})to hide formatting marks on inactive lines, creating zero-width gaps that cause CM6’s position mapping to collapse. Originally fixed by interceptingDecoration.replace({})viaRangeSetBuilder.prototype.addpatching. Later replaced with a CM6EditorState.transactionFilterapproach (see [Unreleased] section) due to conflicts with obsidian-latex-suite. %bracket matching skips brackets in strings and comments — the fork’sscanForBracketfallback now callsgetTokenTypeAt()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 respectsshiftwidthandexpandtab— the fork’s indent operator now reads the vim optionsshiftwidthandexpandtab(viagetOption()) before falling back to CM6’stabSizeandindentWithTabs. Whenset shiftwidth=2orset expandtabis set in.obsidian.vimrc, the indent operator uses those values for both visual-block and line-by-line indentation.Vlinewise 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’smakeCmSelectionwas settinghead.ch = lineLength(line)for display, which placed the cursor at the end of the line. AViewPluginwithDecoration.linenow 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
applyVimrcMapscall runs before the CM6 vim extension has fully settled, the delayed retry ensures mappings take effect.
Changed
minAppVersionbumped to 1.2.3 — required forsetDisabled()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 documentingPlatform.isMobileguards and feature-by-platform compatibility matrixREADME.md: updated Requirements from “Desktop only” to “Desktop and mobile” with link to known limitationsKNOWN_LIMITATIONS.md: added “Insert mode escape” section documenting thekeydown-based handler, configurable timeout, and thevim-keypressevent limitation; updatedvi*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; removedVlinewise cursor deviation; updatednmap L $section with investigation findings; added “Formatting mark cursor correction” sectionREADME.md: addedinsertmodeescapetimeoutto number options table and vimrc example; added insert mode escape timeout to settings listDIFFERENCES.md(fork): added sections forscanForBracketstring/comment awareness, indent operatorshiftwidth/expandtabsupport, 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.Vimwith the built-in API),getVimApi()returns the built-in Vim object and the calls throwTypeError: … is not a function. Addedtypeofguards to all three call sites and marked the methods as optional in theVimApitype definition. (#29)vim.resetKeymap()inonload()— prevented the plugin from loading entirelyvimApi.clearInputState(cm, 'pane-switch')in theactive-leaf-changehandler — crashed on every tab switch when a partial key buffer was pendingthis.vim.removeMapCommand(reg.keys)inVimRegistration.removeRegistration()— crashed during plugin unload or feature toggle when cleaning upmapCommandregistrations
[0.21.1] - 2026-06-29
Fixed
- Space-as-leader key mappings not matching in codemirror-vim —
Vim.map(' j', 'gj')andVim.mapCommand(' w', ...)stored literal space in the keymap (' j'), butvimKeyFromEventproduces'<Space>'on key press. ThecommandMatchstring comparison never found a match, so leader-prefixed sequences silently failed. The fork now normalizes literal spaces to<Space>in_mapCommand(bothkeysandtoKeys),unmap(), andremoveMapCommand(). 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 (unmapDefaultBindingcentralization) was necessary but not sufficient without this keymap normalization. (#21) - Vimrc map commands registered twice —
nmap,nnoremap, and other map commands in.obsidian.vimrcwere processed once correctly viadeferredMaps(the plugin’s own parser) and then a second time viavim.handleEx()(codemirror-vim’s ex command parser). ThehandleExpath splits arguments on whitespace, sonmap <leader>j gjwith space as leader becamenmap j gj— a barej → gjmapping without the leader prefix. This double-registration was masked for non-space leaders (comma, backslash) because whitespace splitting doesn’t affect those characters. Addedcontinueafter thedeferredMaps.push()block, matching the pattern used by all other handled command types (let,source,set). (#21)
Documentation
DIFFERENCES.md(fork): added “Key string normalization formap/mapCommand” section documentingnormalizeKeyStringand the_mapCommand/unmap/removeMapCommandnormalization pointsKNOWN_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— pressingoorOon 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 foro(below) and keep the same number forO(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 thevimApiobject for action introspection, enabling the save/restore pattern for built-in action overrides - Plugin: added
defineActionOverridemethod toVimRegistrationthat captures the original action before overriding and restores it on plugin unload — ensuringo/Orevert to default vim behavior when the plugin is disabled
- Fork: added
- Fork test count: 1690 (up from 1686, 4 new
getActionAPI tests) - E2E test suite
test/specs/open-line-list.e2e.tswith 35 tests covering all list types, indentation levels, blockquotes, nested blockquotes, code block exclusion, undo, and edge cases
Fixed
Oon first line after frontmatter behaves likeo— pressingOon 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:
newLineAndEnterInsertModeinvim.jscomparedinsertAt.line === cm.firstLine()— always false when frontmatter is present. Now scans past----delimited frontmatter to find the first editable line and usesinsertAt.line <= firstEditableas the boundary check. The insertion point uses{ line: insertAt.line, ch: 0 }instead of hardcodedfirstLine(), so it works for all line types (plain text, headings, etc.) with or without frontmatter. - Plugin: the smart list continuation override in
open-line.tshad the samecurLine === cm.firstLine()issue. AddedfirstEditableLine()helper with the same frontmatter scan, changed the boundary check tocurLine <= firstEditableLine(cm), and updated the insertion point to{ line: curLine, ch: 0 }.
- Fork:
- E2E regression tests for
o/Owith frontmatter:Oon unordered/ordered/task list after frontmatter inserts above,oafter frontmatter inserts below,Oon non-list line after frontmatter inserts above,oon non-list line after frontmatter inserts below,Oon second line after frontmatter uses normal insertion path gkon wrapped line after frontmatter jumps straight to properties — when the first line below the frontmatter wraps across multiple display lines,gknow correctly navigates through the wrapped display lines before entering the properties panel. Previously, thestuckAtBoundarycheck in the fork’sfindPosVtreated display-line movement within a wrapped line as “stuck” (same document line) and immediately firedfocusBefore. 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>→lbinding in codemirror-vim’s keymap consumed the space keystroke before leader-prefixed sequences could accumulate. Previously,unmapDefaultBinding(leader)was only called insideregisterEasyMotion(), so the fix only applied when EasyMotion was enabled. The plugin now unmaps the leader key’s default binding centrally — after vimrc loading, inreregisterLeaderFeatures(), and inreloadFeatures()— 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 loadinglet mapleader = ","instead oflet 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
gkwrapped-line frontmatter edge case:gknavigates display lines on wrapped first content line,gkenters properties on non-wrapping first content line,kenters 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 theOboundary fixDIFFERENCES.md(fork): added “Frontmatter-awareO(open line above)” section documenting thenewLineAndEnterInsertModefixREADME.md: updated smart list continuation description to mention frontmatter awarenessREADME.md: updated settings list to reflect new section grouping and orderingKNOWN_LIMITATIONS.md: updated “Properties navigation” section with wrapped-linestuckAtBoundaryedge case fixDIFFERENCES.md(fork): updated “Properties navigation” section withrange.head === startOffsetguard
[0.20.0] - 2026-06-29
Fixed
let mapleader = ","(comma) and other keys with default Vim bindings not working as leader for EasyMotion —unmapDefaultBindingnow passes{ includeDefaults: true }tovim.unmap(), so built-in codemirror-vim bindings (e.g.,→repeatLastCharacterSearch,;→ forward repeat) are actually removed before registering EasyMotionmapCommandmulti-key sequences. Previously,vim.unmap()silently skipped_isDefaultkeymap 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 incommandMatch. (#6)gg/Gand 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 viaunmap()removing default entries, incompleteleaveVimMode()cleanup leaking insert-mode listeners, and async motion race conditions. (#18)- Fork: blur handler resets partial key prefixes — the CM6 ViewPlugin now registers a
blurlistener oncontentDOMthat callsclearInputState()when the editor loses focus in normal mode. A stale prefix likegno longer persists across tab switches or modal opens, preventing the next keystroke from being silently swallowed. - Fork:
leaveVimMode()cleanup hardened — now removes insert-modechange/keydownlisteners if the editor was destroyed while in insert mode, clears the globallastInsertModeKeyTimer, clearsvirtualPrompt, and resetsinputStatebefore nullingcm.state.vim. - Fork: default keymaps protected from
unmap()— default keymap entries are tagged with_isDefaultand a frozen snapshot is stored at module init.unmap()now skips default entries unless explicitly requested. NewVim.resetKeymap()API restores defaults from the snapshot while preserving user mappings.mapclear()updated to use the_isDefaultflag instead of fragile index-based partitioning. - Fork: async motion generation tracking —
_commandGenerationcounter 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-changehandler now clears pending vim input state on all editors when switching panes, preventing partial commands from leaking across editors. - Plugin:
resetKeymap()on load — callsVim.resetKeymap()during pluginonload()to ensure a clean keymap baseline on plugin enable/reload, recovering from any prior corruption in the same app session.
- Fork: blur handler resets partial key prefixes — the CM6 ViewPlugin now registers a
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-groupCSS 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 keystext. Built-in features register default labels (Table, EasyMotion) that can be overridden. Labels support<leader>token expansion (e.g.<leader>tresolves to the actual leader key +t). Configurable via Settings → Vim Motions → Which-key group labels. - E2E test suite
test/specs/vim-state-hardening.e2e.tswith 7 tests: blur prefix recovery,gg/Gafter plugin reload, keymap protection viaunmap(),resetKeymap()recovery after force-unmap,leaveVimModecleanup from insert mode - Fork unit tests: 10 new tests for async motion generation tracking (superseded motion discarded, superseded delete discarded), keymap protection (
unmapskips defaults,unmapremoves user mapping preserving default,unmap ggpreserves default,resetKeymaprestores after force-unmap,resetKeymappreserves user mappings,mapclearpreserves defaults),leaveVimModecleanup (clears input state, cleanup from insert mode) - Fork test count: 1672 (up from 1660)
Documentation
DIFFERENCES.md(fork): added sections for blur handler,leaveVimModecleanup hardening, default keymap protection (_isDefaulttagging,resetKeymap(),mapclear()update), async motion generation tracking,clearInputStateAPI exposureKNOWN_LIMITATIONS.md: added “Vim state hardening” section documenting the multi-layered defense against intermittent keymap breakageREADME.md: added “Improved vim state reliability” bullet to recommended setup section
[0.19.0] - 2026-06-27
Fixed
k/gkdo not enter frontmatter navigation — bothkandgknow enter the properties panel when the cursor is at the top of a note. Two fixes: (1) the fork’smoveByDisplayLineswas missing thefocusBeforecheck thatmoveByLinesalready had, and (2) the fork’sfindPosVfrontmatter 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’stableAwareMoveUpmotion (which overrideskfor table separator skipping) bypassedfindPosVentirely — it now delegates tofindPosVwhen the target line is inside the frontmatter. (#25)gk/gjover headings resets cursor to column 0 —gk(andgj) 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’sfindPosVwidget-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 aposAtCoordsfallback corrects cases wheremoveVerticallymisresolves the goalColumn on decorated lines. (#26)
Added
gD— open link in new tab —gDopens the link under the cursor in a new tab, using the same bracket-aware link detection asgd. External URLs open in the browser. (#23)<C-w>gd/<C-w>gD— open link in split —<C-w>gdopens the link under the cursor in a horizontal split,<C-w>gDin a vertical split. Follows the Neovim<C-w>s/<C-w>vconvention (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/gjover headings: cursor horizontal position preserved across single and multiple headings, symmetry betweengkandgj
Documentation
KNOWN_LIMITATIONS.md: updated “Properties navigation” section withk/gkfrontmatter fix andtableAwareMoveUpinteractionKNOWN_LIMITATIONS.md: addedgkfrontmatter entry to behavioral deviations tableDIFFERENCES.md(fork): updated “Properties navigation” section with boundary detection and dual-casefocusBeforelogicKNOWN_LIMITATIONS.md: updated “Visual line navigation and replaced widget decorations” section with heading-aware fix andposAtCoordsfallbackKNOWN_LIMITATIONS.md: updatedgj/gkwidgets behavioral deviation entry with heading decoration handlingREADME.md: addedgD,<C-w>gd,<C-w>gDto 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 toscrollDOMas a sibling of the wrapper, so itsright: 0; bottom: 0resolves against the full editor dimensions. (#6) - EasyMotion labels overlapping on dense text — labels for adjacent targets (e.g.,
<leader><leader>won 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 whosecoordsAtPos()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
dimmingparameter was changed from a capturedbooleanto a() => booleangetter, 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’sexitVisualMode()calledclipCursorToContent()whilevim.visualModewas stilltrue, which allowed the cursor to land at the linebreak position; after clearing the flag, the cursor remained displaced. Fixed by clearing visual flags beforesetCursor. Also fixed a latent JS loose equality bug inmeasureCursor()wherefalse != "\n"evaluated tofalsedue to type coercion. (#15) - Leader key mappings not working via vimrc —
let mapleader = ","(or space, or any custom leader) in.obsidian.vimrcnow correctly re-registers EasyMotion, hint mode, table manipulation, and settings leader bindings with the new leader key. Previously, the initial backslash-leadermapCommandentries persisted in the keymap becauseVim.unmap()could not removemapCommand-created entries, andunmapDefaultBindingskipped non-special keys like comma. The fork now providesVim.removeMapCommand(keys)for clean removal, andVimRegistrationuses 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>onow work correctly when Obsidian’s default Ctrl+W hotkey is unbound. The fork’smatchCommandhad anidleentry 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>Xsequences from matching. The fork now deprioritizesidlefull matches when more-specific partial matches exist (e.g.<C-w>v,<C-w>hregistered viamapCommand). Theidleentry 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_clippingcoveringvlll<Esc>,vll<Esc>, andv$<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>sverify leaf count increases (split created),<C-w>cverifies leaf count decreases (tab closed),<C-w>overifies other tabs closed,<C-w>h/j/k/lverify 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 withexitVisualModeroot cause andmeasureCursorcoercion fixDIFFERENCES.md(fork): updated “Visual mode cursor positioning at EOL” section withexitVisualModeordering fix and strict equality fixKNOWN_LIMITATIONS.md: expanded “EasyMotion leader key conflict” fixed section with leader re-registration andremoveMapCommanddetailsKNOWN_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 “removeMapCommandAPI” section documenting the new keymap removal methodDIFFERENCES.md(fork): added “Idle key deprioritization for multi-key sequences” section documenting thematchCommandfix
[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
TableRenderWidgetrenders markdown tables as HTML using Obsidian’s CSS classes (cm-embed-block,markdown-rendered,table-wrapper,table-cell-wrapper) for full theme compatibility StateFieldprovidesDecoration.replacefor tables the cursor is NOT in; removes decoration when cursor enters- Table widget suppressor patches
RangeSetBuilder.prototype.addto 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
- Custom
- Vertical table cell navigation —
]r/[rmoves 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 (likei()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>trmapping. Computes column widths across all rows, pads cells uniformly, and respects:---/---:/:---:alignment markers in separator rows. - Table auto-format on
|— CM6inputHandlerextension 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>tprefix 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.tsprovides safe prototype patching with automatic removal, replacing the externalmonkey-arounddependency - E2E test suite expansion: 28 tests in
table-cell-bridge.e2e.ts(cursor-aware rendering, widget suppression,j/knavigation through tables, separator row traversal, post-edit navigation, theme class verification, alignment rendering), 24 tests intables.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
kfrom crossing the separator row (|---|---|). Fixed with a customtableAwareMoveUpmotion 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
TableRenderWidgetinstead 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
TableCellBridgeapproach (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 aStateField. tableWidgetModesetting default:'cursor'(cursor-aware rendering)- Legacy
suppressTableWidget: booleansetting 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 architectureREADME.md: updated table navigation description for cursor-aware rendering; addedi|/a|to text objects table,]r/[rto navigation, table text objects section, auto-format docs,<Leader>trand:tablerealignto 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 beforeregisterEditorExtension()for earlier availability, and properly cleaned up on plugin unload. (#17) Vim.enterInsertMode(cm)missing from fork API — ecosystem plugins (Outliner, obsidian-lineage) callVim.enterInsertMode(cm)to transition the editor into insert mode after custom actions. Obsidian’s built-invim.jsexposes this method but upstream@replit/codemirror-vimdoes not. The fork now exportsenterInsertMode(cm)on theVimsingleton, matching Obsidian’s API surface. Without this, plugins using the bundled fork would getTypeError: vim.enterInsertMode is not a function. (#17)
Changed
- Fork test count — 1630 fork tests passing (up from 1628). Added
api_enterInsertModetest verifying the new public API method.
Documentation
DIFFERENCES.md(fork): added “enterInsertModeAPI 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,yand prefix keys likeg,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 …) showgetCompletions()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
- Operator-pending mode (
- E2E test suite
test/specs/which-key.e2e.tswith 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 —
enableWhichKeyboolean replaced withwhichKeyModedropdown ('off'|'leader'|'all'). Default changed from implicit leader-only to explicit'off'. - Which-key overlay rewritten —
WhichKeyOverlayclass generalized from leader-only to support any partial key sequence. UsesgetInputState()for operator-pending detection andvim.statusfor partial key chord display. DOM attachment changed fromeditorEl.parentElementtoview.contentElfor reliable positioning. VimStatetype fix —modefield changed from required'normal' | 'insert' | 'visual' | 'replace'to optionalstringto 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.tsreduced from 20 to 10 entries. 10 deviations removed after verifying the fork now matches Neovim:)sentence motion,di(multiline,dbcross-line,dwempty line,d2wcross-line,dgeempty lines,diwword boundary,da"trailing space,:joincursor,:globalcursor. - Fork test count — 17 new fork-level tests for async motion dispatch (6),
getKeymap()API (5), andgetCompletions()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
:sflag behavior.
Fixed
da"trailing space —da"onsay "hello world" endnow producessay end(single space) instead ofsay end(double space). The fork’sfindBeginningAndEndnow consumes adjacent whitespace after inclusive quote expansion, matching Neovim.:joincursor position —:joinex command now positions cursor at column 0 of the joined line, matching Neovim. Previously placed cursor at the join point.:globalcursor position —:g/pattern/dnow positions cursor at the last matched line after execution, matching Neovim. Non-destructive:gcommands leave cursor where the last sub-command placed it.- Empty
:sflag behavior —:swith no arguments no longer preserves the/gflag from the previous substitution. Only the first match on the line is replaced, matching Neovim. %string-awareness — updatedKNOWN_LIMITATIONS.mdto reflect that%is only partially fixed: the forward-seek check works, butfindMatchingBracketstill does positional counting without string awareness.- Which-key graceful degradation —
getInputState(),getKeymap(), andgetCompletions()calls in the which-key overlay now checktypeofbefore 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) andguu(content unchanged→lowercased). Full vim-builtin e2e suite now passes 16/16.
Documentation
DIFFERENCES.md(fork): added “Keymap introspection API” section documentinggetKeymap()andgetCompletions()DIFFERENCES.md(fork): updated “Empty :s flag preservation” → “Empty :s uses default flags”, addedda"whitespace,:joincursor,:globalcursor sections, updated golden comparison statsKNOWN_LIMITATIONS.md: “Which-key overlay scope” section rewritten to reflect the new all-keys modeKNOWN_LIMITATIONS.md: updated%+ strings entry to “Partially fixed” with explanation of remainingfindMatchingBracketlimitationKNOWN_LIMITATIONS.md: addedda",:join,:global,:sempty entries to behavioral deviations tableKNOWN_LIMITATIONS.md: correctedvi*single-char status from “Fixed” to “Not fixed”AGENTS.md: updated fork test count (1421→1628) and golden comparison statsREADME.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$,vlto EOL), the block cursor no longer renders one character past the visible line content. The fork’smeasureCursor()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=unnamednot syncing to system clipboard —set clipboard=unnamed(orunnamedplus) in.obsidian.vimrcnow actually syncs yank, delete, and change operations with the system clipboard. Previously, the option was parsed and stored but never acted upon — only explicit"+yregister 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), visualS{replacement}(selection) - Tag surround:
dst(delete surrounding tag),cst{replacement}(change tag),ysiw<tag>(surround with tag),cs"<tag>(delimiter to tag), visualS<tag>(selection with tag). Regex tag fallback for Markdown mode. - Function wrapping:
ysiwf+ name + Enter →name(text),ysiwFfor 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→>), andt(tag) target - 1585 fork tests passing
- Core:
- E2E test suite
test/specs/surround.e2e.tswith 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 changesREADME.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/gkandj/know navigate into rendered MathJax$$blocks line by line instead of skipping over them. The fork’sfindPosVdetects whenmoveVerticallyjumps 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 asi*/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 —.aftercwcorrectly replays the inserted text (was a test infrastructure issue, not a vim engine bug) - Search
n/Nwrap-around —nafter/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 tovim-command-donein addition tovim-keypressandvim-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 vimrcset guicursor=n:block,i:bar,v:hollow,r:underline,o:underline. Requires bundled fork mode. (#13) - E2E test suite
test/specs/widget-navigation.e2e.tswith 6 regression tests for gj/gk/j/k navigation through rendered MathJax$$blocks in live preview
Changed
i$/a$text objects now usecreateSmartDollarTextObject(tries$$first, falls back to$), matching the same disambiguation pattern asi*/a*withcreateSmartAsteriskTextObject
[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-vimVisualclass and scopes its::selection { transparent }rule to non-visual modes only. (#10) - Properties navigation — pressing
kat the top of the document now navigates into the properties (YAML frontmatter) panel, matching built-in vim behavior. The fork’sfindPosVadapter detects whenmoveVerticallylands the cursor inside the frontmatter region and provides afocusBeforecallback that focuses the “Add property” button. (#11) - Latex Suite compatibility — bundled vim extension now registered at
Prec.highestso its keydown handler fires before Latex Suite’s handlers, preventing duplicate key consumption in large math blocks. (#11) - Empty
:sflag handling —:swith 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 defaultnrformats - 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 —
redrawaftersetCursorprevents stale Neovim state; 80×24 viewport simulation viaset columns=80 lines=24enables accurate display-line motion recording zc/zofold commands — fold/unfold now use CM6’sfoldCode/unfoldCodedirectly instead of Obsidian’s incrementaleditor:fold-more/editor:fold-lesscommands, which operated globally by heading level rather than at the cursor position.zausestoggleFoldfor robust cursor-based toggling. (#8)
Changed
- Bundled vim extension registered at
Prec.highestfor 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-vimas a CM6 extension with Neovim-parity behavioral fixes. Awindow.CodeMirrorAdapter.Vimbridge ensures ecosystem plugins (obsidian-vimrc-support, vim-im-control, etc.) work transparently. - Async motion support — the fork’s
defineMotionnow accepts async functions returningPromise<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/d2wcross-line whitespace,ddcursor column preservation,Jtrailing 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,easyMotionBdTillavailable as named actions for vimrc remapping - Repeat last easymotion motion —
easyMotionRepeataction 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 CM6dispatch({ selection })to manipulate the selection range directly. - EasyMotion dimming setting —
easyMotionDimmingtoggle in settings UI - Spike test
test/specs/spikes/spike19-easymotion-visual.e2e.tsinvestigating CM Vim visual mode and operator-pending feasibility (6 questions answered) - E2E test file
test/specs/easymotion-comprehensive.e2e.tswith 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.tswith 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-firstand.vim-motions-easymotion-label-second(2-char label styling)
Fixed
ddcursor column preservation — cursor now stays at its original column after linewise delete (matching Neovim), instead of moving to first non-blank characterJtrailing whitespace — join now strips trailing whitespace from the current line before adding the join space, preventing double spacesdi{/di[/di<multiline — inner bracket text objects on multiline brackets now preserve the bracket lines (producinga{\n}binstead ofa{}b), matching Neovimdj/dkat document boundary —djon the last line anddkon the first line are now no-ops (matching Neovim), instead of deleting the content:scursor 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 Neovimdb/d2wcross-line whitespace — when a delete crosses a line boundary, the whitespace-only prefix before the cursor is now included in the deletion, matching Neovimdgeat document start —geat the start of the document is now a no-op instead of deleting the character under cursordgeon empty lines —dgeon double-empty-lines now deletes both lines (matching Neovim) instead of leaving one]ptab remainder —]pwithindentWithTabsnow 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 ofVim.handleKeywhich bypasses the DOM listener - Hint mode escape dismissal — same fix as EasyMotion
- Workspace test isolation — workspace tests now use
beforeEachwithloadSingleFileWorkspace()to prevent cascading failures fromgdnavigation - Settings reload Y/Q test — uses
Vim.handleKeyinstead ofbrowser.keysto avoid DOM event routing issues afterreloadFeatures() - Vim cursor styling — fork’s hardcoded
#ff9696cursor color replaced with Obsidian CSS variables (--interactive-accent,--text-on-accent) directly in the fork’sblock-cursor.tswith 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
dwon empty line cursor — cursor afterdwon an empty line before a whitespace-only line now positions atch:1instead ofch:0- Ambient type declarations —
src/types/codemirror-vim.d.tsprovides fallback types forvim(),getCM(), andVimwhen 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, returningPromise<Pos>) instead ofdefineAction. 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>fare now forward-only, matching vim-easymotion parity. Previously these scanned the entire visible viewport regardless of cursor position.registerEasyMotion()now accepts adimmingparameter and uses a data-drivenEASYMOTION_DEFSarray for registration instead of per-motion imperative codeshowOverlay()returns anOverlayHandlewithupdateLabels()for dynamic re-rendering during 2-char label narrowingwaitForLabel()replaceswaitForKey()as the primary label capture function, supporting multi-char labels, backspace reset, and narrowing callbacks- Removed
test/specs/easymotion-motions.e2e.ts— superseded byeasymotion-comprehensive.e2e.tswith correct async test patterns for char-input motions - Fork dependency —
@replit/codemirror-vimnow referenceshttps://github.com/saberzero1/codemirror-vim.gitinstead of a local file path, enabling CI/scanner environments to install without local checkouts reportUnusedDisableDirectivesset tooffin eslint config to avoid conflicts between local and scanner lint rule sets- Added
Obsidianto sentence-case brands list in eslint config
Documentation
KNOWN_LIMITATIONS.md: EasyMotion operator-pending rewritten — now uses async motions natively instead of capture-phase interceptorKNOWN_LIMITATIONS.md: added 8 behavioral deviation entries for fork fixes (ddcursor,Jwhitespace,di{}multiline,dj/dkboundary,:scursor,%strings,dbcross-line,dwcursor)KNOWN_LIMITATIONS.md: added “DOM keyboard events not routed after settings reload” and “EasyMotion visual mode label selection via DOM events” sectionsAGENTS.md: added codemirror-vim fork section with dual-vim architecture documentationREADME.md: added “Recommended setup” section explaining benefits of disabling built-in vimDIFFERENCES.md(fork): comprehensive rewrite documenting all behavioral fixes and infrastructure changesDIFFERENCES.md(fork): added widget-aware vertical navigation and per-mode cursor shapes sectionsKNOWN_LIMITATIONS.md: added “Visual line navigation and replaced widget decorations” sectionKNOWN_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.defaultLineHeightto 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. adjustRangeForVisualModeno 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-vimmakeCmSelectionbug still preventsvi*on*x*from selecting correctly, butdi*on*x*now works as expected.)
Changed
getTextwidth()now reads directly from the plugin’s internaltextwidthValueinstead of queryingvimApiRef.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()forset textwidth=Nlines and handles them entirely viasetTextwidth()+vim.setOption(), preventing CM Vim’s Ex handler from interfering with the plugin’s textwidth state. syncTextwidthFromVim()removed — the function read CM Vim’sgetOption('textwidth')which returned the stale default (80) during theactive-leaf-changelifecycle, overwriting the correct vimrc-set value.findFenceLines()andfindContainingBlock()exported fromsrc/text-objects/code-block.tsfor reuse in delimiter scanning.MULTILINE_SCAN_LIMITconstant removed fromdelimiter.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 textwidthvia vimrc” root cause refined — identified CM Vim’sdefineOptioncallback resetting the value during editor initialization.KNOWN_LIMITATIONS.md: “dGleaves trailing newline” updated from “Skipped test, pending fix” to “Unfixable from plugin code” with investigation findings.KNOWN_LIMITATIONS.md: “Dot-repeat ofcw” and “n/Nsearch 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 internalvim.statusstring 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. ModePromptsinterface andDEFAULT_MODE_PROMPTSconstant exported fromsettings.ts.VimModeTrackerOptionsextended withpowerlineandmodePromptsfields.- 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
hintModeLabelssetting controls the character pool for hint labels (default:asdfghjkl). - Independent settings toggle:
enableHintModesetting 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-openevent. - Editor pane navigation:
.workspace-leaf-contentis now a hint target. Selecting it callssetActiveLeaf()with focus and activates the editor, matching click-to-focus behavior. - Smarter element activation:
contenteditableelements receive.focus(), internal links useapp.workspace.openLinkText(), Ctrl/Cmd+click opens in new pane viaMouseEventdispatch. - 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>hworks 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, andTARGET_SELECTORexported fromhint-mode.tsfor testability.- E2E test suite
test/specs/hint-mode.e2e.tswith 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 insettings.tsfor displaying serialized hotkey strings in human-readable form.- CSS class
.vim-motions-hotkey-displayfor the hotkey display in settings.
Changed
- Hint mode registration extracted from
registerWorkspaceNavigation()into a standaloneregisterHintMode()private method on the plugin, following the same pattern asregisterEasyMotion(). createHintModeAction()now accepts an optionalhintCharsparameter for configurable hint character pools.isVisible()now checks against scrollable ancestor containers (not just the viewport) — elements scrolled out of view insideoverflow: hidden/scroll/autoparents are excluded.showHints()refactored to usegetHintPosition()which places.workspace-leaf-contentlabels at the editor/preview content area (8px inset) rather than the top-left of the leaf container.waitForHintKey()now returnsHintResultwithctrlKey/metaKeymodifier 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/scrollYreplaced withactiveWindow.*equivalents throughout hint mode. - Hotkey recorder uses
e.codeas fallback whene.keyreports'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
keydownlisteners on the main window’s document that bypass Obsidian’s scope system. - Selecting a
.workspace-leaf-contenthint now properly focuses the editor pane viaapp.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 EasyMotionmapCommandentries. (#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 forletto preserve quoted values containing whitespace. - Vimrc loading no longer falsely reports “loaded but contained no commands” when the editor isn’t ready.
loadVimrcnow 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 betweenactive-leaf-changeand 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’smakeCmSelectionadds +1 to the head position in visual mode, and built-in text objects compensate via an internalexpandSelectionhelper, but customdefineMotiontext objects bypassed that path. (#4) ]bwith a single buffer no longer opens a stale file from a previous session’s recent-files list.vgq(visual modegq) no longer triggers macro recording. Thevim-keypresshandler for macro recording previously intercepted theqkeystroke ingqas a macro-record toggle. Fixed by restricting macro recording to normal mode only (matching Vim behavior), tracking previous keypress to detectg-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) somapCommandmulti-key sequences starting with that key can accumulate in the input buffer.VimrcLoadResult.readyfield — 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+mapCommandapproach 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 usingobsidianPage.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
gqin visual mode (wrap + no macro recording),gqqmacro non-interference, and standaloneqmacro recording start/stop. - 3 Neovim golden comparison cases for
gqoperators (gqq,Vgq,gqj) added to theg-commandssuite with content deviation registered (Markdown-aware wrapping differs from Neovim’s plain-textgq).
Changed
registerEasyMotion()now callsreg.unmapDefaultBinding(leader)before registeringmapCommandentries, allowing any single-character leader key to work.registerWorkspaceNavigation()hint mode binding uses the resolved leader key fromLeaderRegistry(same approach as EasyMotion).createHintModeAction()return type narrowed fromActionFnto() => void(the function ignores all parameters).VimrcLoadResultgainsready: booleanfield;loadVimrc()returnsready: falsewhen the editor adapter is unavailable.- Vimrc
active-leaf-changecallback retriesloadVimrcup 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 withmapCommand” 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 wherevi*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 theyamlpackage, fixingnpm run test:coveragewhich 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/daBon 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/gpto use direct register-reading implementation instead of delegating throughVim.handleKey, avoiding re-entrancy issues with the newPoverride.
Neovim test infrastructure
- Ex commands (
:s,:sort,:d,:yank,:join,:noh,:undo,:redo,:global) now work correctly in Neovim golden comparison tests — addeddispatchVimKeysrouting that detects Ex command sequences and dispatches them viaVim.handleEx()instead of character-by-character key input.
Changed
test/neovim/deviations.tsreduced 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.mdbehavioral deviations table expanded with 5 entries for confirmed upstream constraints (dG,>>,V+>,d0,<<) that cannot be intercepted viamapCommanddue to codemirror-vim’s operator-pending dispatch architecture.- Replaced
js-yamldependency withyaml— better maintained, YAML 1.2 spec-compliant, ships its own types. - All 16 Tier 1 test files (
test/specs/vim-builtin/*.e2e.ts) now usetestWithNeovim()as the primary test format alongside existingit()blocks. Neovim lifecycle hooks (startNvim/stopNvim) added to top-levelbefore/after. test/helpers.ts— addedvimRawKeys()for raw byte key sequences (supports\x1bfor Escape,\x01-\x1afor Ctrl keys,\nfor 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 officialneovimnpm package. Spawnsnvim --embed --headless, providessetContent(),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 withloadGoldenFile(),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/geacross empty lines, at document boundaries, with punctuation, count clipping, line wrapping. - Operator edge cases:
dwat end of line,ddon last/only line,d2w/2dd,D,dk,djon last line,de/db,dG/dgg,dfx/dtx,cwvsce,cc/C/2cc. - Text object edge cases:
iw/awon whitespace,iW/aWwith mixed punctuation, nestedi(/i{/i[,di(across lines,d2awwith count,i"with escaped quotes. - Character search edge cases:
f/tnot crossing line boundaries,2t/2Fcounts,;aftert,,reversal. - Visual mode edge cases:
viw,v3l+d,gvreselect,V+ylinewise, visual at document boundaries. - Yank/register edge cases:
yy/ywlinewise flag,y$without newline, numbered register rotation,"Ayyappend,".last inserted text. - Repeat edge cases:
.afterdw/>>/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 (dGtrailing newline,iBnesting,di*on delimiter, dot-repeat ofcw,)cursor off-by-one,n/Nwrap-around).
[0.5.1] - 2026-06-19
Fixed
.obsidian.vimrcis 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 toy$; overrides CM Vim’syybehavior)ga— show character info under cursor (codepoint, hex, octal) via Noticegp— paste and move cursor past pasted textgn/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-inexpandToTagis 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) withsetupEditor,getCursorPos,getEditorValue,getRegisterContent,getVimMode,vimKeys, and timing constants unsupported()anddeviation()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 vianpm 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/lpane focus,H/M/Lscreen-relative motions,?backward search,zO/zC/zArecursive folds, and all new Ex commands - E2E test for scrolloff hot-reload: verifies scroll margins update when
scrolloffLineschanges - E2E test for
Y/Qindependence from workspace navigation: verifiesYstill 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-paddingwhich CodeMirror 6 ignores (it uses manual scroll calculations, notElement.scrollIntoView). Replaced withEditorView.scrollMarginsfacet, 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
onChangehandler did not triggerreloadFeatures()andreloadFeatures()itself had no scrolloff handling - Removed deprecated
setDynamicTooltip()call on scrolloff slider — the value is now always shown inline by Obsidian Y(y$) andQ(@@) Neovim default remaps now work regardless of the “Workspace navigation” toggle — previously these were registered insideregisterWorkspaceNavigation()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-dependencieserror on@codemirror/view— addedimport/core-modulessetting andpeerDependenciesfor@codemirror/*packages provided by Obsidian at runtime - Removed unused variables:
totalLinesintag.ts,openEndIndex/closeStartIndexintag.ts,activeincommands.ts,newLeafincommands.ts
Changed
- Scrolloff implementation rewritten from CSS
scroll-paddinginline styles toEditorView.scrollMarginsextension registered viaregisterEditorExtension. TheScrolloffManagerclass 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.tsinstead of locally definedgetEditorValue,getCursorLine, andvimKeysfunctions - Test-vault
hotkeys.jsonnow unbinds Obsidian shortcuts that conflict with Vim commands (Ctrl+W,Ctrl+N,Ctrl+P,Ctrl+S,Ctrl+O) - Tag text objects (
it/at) changed fromunsupportedskip to working plugin-implemented text objects ChangeListclass gainsgetEntries()andgetIndex()public accessors for the:changesEx commandYandQNeovim default remaps moved fromregisterWorkspaceNavigation()to the always-on initialization path inonload()andreloadFeatures()- Vimrc loader’s
loadVimrc()now returns aVimrcLoadResultwithfound,commandCount,path, andmapsfields - Vimrc loader refactored to use
vim.handleEx()for command application instead of directvim.map()/vim.setOption()API calls, improving compatibility with obsidian-vimrc-support configurations - Vimrc loader now collects parsed map commands as
DeferredMapentries and re-applies them viavim.map()/vim.noremap()on subsequentactive-leaf-changeevents, attempting to restore mappings that CM Vim may lose during editor reinitialization - Vimrc loader intercepts
set textwidth=N/set tw=Nlines and directly updates the plugin’s internaltextwidthValue, bypassing CM Vim’s option callback chain getTextwidth()now reads from CM Vim’s option viaVim.getOption('textwidth')as a fallback when the plugin’s internal value hasn’t been updated- Vimrc loading deferred to first
active-leaf-changeevent to guarantee editor availability, matching obsidian-vimrc-support’s loading strategy
Documentation
- README: added
:wa/:wallto Ex commands table,g<C-t>to workspace keybindings table - README: corrected
set textwidth=Nclaim — now notes the known limitation and provides the runtime workaround via developer console KNOWN_LIMITATIONS.mdexpanded with comprehensive “Neovim Ex commands not applicable in Obsidian” section covering 30+ commands across 8 categories (shell, quickfix, tags, scripting, diff, etc.) with specific reasoningKNOWN_LIMITATIONS.mdexpanded 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 findingsKNOWN_LIMITATIONS.md: added “set textwidthvia vimrc does not affectgq” section with root cause analysisKNOWN_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
setCssPropsprototype augmentation with standardel.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-dimmedCSS 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
gdon 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 pathgdon 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 inmapCommandwhich could never match typed input - Hint mode (
<leader><leader>h) same fix as EasyMotion - Leader key bindings configured via settings UI or
.obsidian.vimrcnow work when workspace navigation is disabled —:obex 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
:obregistration - Leader key bindings survive settings hot-reload —
:obis re-registered inreloadFeatures()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()resetpendingLeaderstate, preventing dismissal - Which-key overlay no longer leaks
active-leaf-changeevent listeners on destroy ExCommandSuggestis rebuilt after settings hot-reload so the completion list stays current
Added
]c/[cas 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
registerObCommandextracted as a standalone function, called unconditionally in bothonload()andreloadFeatures()LeaderBindingnow trackssource('builtin'or'user') to support selective clearing during hot-reloadLeaderRegistrygainsclearBuiltinBindings()for clean re-registration duringreloadFeatures()registerEasyMotion()andregisterWorkspaceNavigation()acceptLeaderRegistryparameter
[0.2.0] - 2026-06-13
Fixed
- Vimrc path now uses
Vault.configDirinstead of hardcoded.obsidian, supporting custom config directories - Setting descriptions use dynamic config directory path
:obwith 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 disambiguationi_/a_— inside/around italic (_..._)i`/a`— inside/around inline codei$/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 blocksiB/aB— inside/around blockquotesio/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 asgqbut 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 tabsgt/gT— next/previous tabgd— go to definition (follow link under cursor)gx— open URL under cursor in browsergf— open file switcher (quick open)gO— document outline navigator (searchable heading list)grn— rename current notegrr— show backlinks to current notegra— context-aware actions for cursor positiong<C-g>— show document statistics (words, lines, characters)za/zc/zo— toggle/close/open fold at cursorzM/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.,
jkto exit insert mode viaset insertmodeescape=jk) - Settings hot-reload (toggle features without restarting Obsidian)
Vimrc loader
- Built-in
.obsidian.vimrcsupport compatible with obsidian-vimrc-support syntax - Supported commands:
map,nmap,imap,vmap,noremap,nnoremap,inoremap,vnoremap,unmap,set,let mapleader,exmap,obcommand,source - Supported
setoptions: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