Known limitations

This document tracks known limitations, architectural constraints, and intentionally deferred features.

For previously fixed issues, see Resolved Issues at the bottom of this document.

Neovim RPC backend

Status: Active-editor text, key delegation, IME composition, frontmatter handling, decorations, fold mirroring, floating windows, workspace/navigation, picker, Harpoon, marks-command, cross-note jumplist bridging, native Oil isolation, Neovim-backed undo-tree sidebar, structural navigation, hard-wrap wiring, and Markdown text objects are implemented on desktop. M7 latency is certified. Measured over N=500 per condition (75 warm-up discarded) on a ~2000-line note with real keystrokes and identical paint resolution: fork p50 16.2 ms / p95 43.4 ms / p99 54.0 ms, RPC p50 8.2 ms / p95 23.5 ms / p99 28.9 ms — deltas of −8.0 / −19.9 / −25.1 ms, inside the ≤25 ms / ≤60 ms thresholds. RPC leads at every percentile and in all four command classes: motions fork 11.1 / RPC 7.1, operators 38.8 / 22.7, insert typing 16.0 / 8.0, undo 25.3 / 17.1. The gate budgets the p50 delta in both directions rather than requiring the fork to win it. An earlier revision did require fork < RPC at p50, on the theory that p50 isolates round-trip cost while the tail is dominated by in-renderer vim work; that inverted once the fork’s renderer work on a 2,004-line note came to cost more than a pipe round-trip, and the suite failed for being too fast. Engagement is proven by proveRpcEngagementAndForkIsolation() and by the delay control, not by the sign of the delta.

The opt-in backend can spawn a user-configured Neovim 0.12+ process, attach over msgpack-RPC, seed one listed acwrite Neovim buffer from the active Markdown editor, and apply Neovim line events back to CM6 byte-exactly at valid UTF-8 boundaries. While connected, a capture-phase handler delegates ordinary keys through nvim_input; the bundled fork is intercepted, CM6 is input-inert, and CM6’s cursor follows Neovim after a blocking RPC barrier drains earlier line notifications. A cursor-positioned input outside CM6’s contentDOM owns native IME composition. Preedit stays local, committed text enters through nvim_input, and composing keydowns are not forwarded. Escape, blur, leaving insert mode, active-note changes, and disconnect cancel unfinished composition. Neovim’s msg_showmode output owns the plugin status bar while RPC is connected; disconnect clears that ownership and restores fork-driven text. The buffer uses the note’s absolute filesystem path as its name and explicitly runs filetype detection, producing filetype=markdown and buftype=acwrite. Every active-leaf change renames and reseeds that buffer from the newly active editor while line-event echo is suppressed; Neovim’s previous mirror is never written into another note. Only the active editor is tracked; multi-leaf and multi-buffer ownership is deferred.

Both Obsidian Properties in document modes are supported. Source-rendered frontmatter remains fully navigable. With rendered properties visible, the dedicated Neovim window uses a local expression fold over start-of-document --- frontmatter; key synchronization resolves any cursor resting on that closed fold to the first body line, and events focused inside .metadata-container remain with Obsidian. The window’s foldlevel sits one below that fold’s level, so the frontmatter closes on activation and the body does not: Vim caps an expression fold at MAX_LEVEL, 20, regardless of foldnestmax, and the frontmatter sentinel is written as that cap, so the only level that separates it from Markdown headings — which reach 6, or 7 through a callout — is 19. foldmethod and foldexpr are reapplied to the mirror window on every activation, because filetype detect re-fires your own FileType handlers each time and a window-local fold expression set there — g:markdown_folding, or a treesitter foldexpr in a personal ftplugin — would otherwise take the window from the second note onward and leave the frontmatter unprotected. foldlevel and foldenable are not reapplied, so zm/zM/zr/zR survive a pane switch. API edits inside the closed fold still mirror the complete document to CM6. Persistent extmarks in visible buffer ranges are forwarded from all namespaces and rendered as CM6 highlights or overlay, eol, and inline virtual text, with Neovim priorities and highlight groups. Floating windows are enumerated during the same redraw provider’s on_end, forwarded as per-window config, buffer lines, and extmarks, and rendered as positioned overlays. editor positions start at the CM6 scroller; cursor positions start at the measured CM6 cursor; win positions include the reference window’s origin. Width, height, row, and column use CM6’s measured default character width and line height, so mapping terminal cells onto proportional Markdown typography is approximate. A fixed 120×40 attached UI supplies redraw cycles and requests messages, command-line, and popup-menu extensions up front. The ordered dispatcher rejects unhandled grid/window events before further work. Errors, warnings, informational echoes, Lua prints, and shell output become severity-styled Obsidian Notices with a five-second duplicate cooldown; undo, search-count, progress, completion, and other routine kinds remain silent. Ephemeral extmarks remain unavailable to an after-the-fact query. matchadd() decorations are outside this bridge because matches are window-local and are not extmarks, so no buffer extmark query returns them. The deprecated nvim_buf_add_highlight() is bridged: it creates an ordinary extmark in the namespace it is given, and namespace-wide forwarding renders it like any other, so plugins still using it display correctly. An RPC text edit whose endpoint splits a UTF-8 sequence is intentionally not byte-exact because CM6 cannot represent invalid UTF-8.

The feature bridge generates Neovim mappings and user commands from the plugin’s tracked Vim registration data and routes them through one obsidian_action notification channel. M4b Batch 1 adds workspace and navigation actions to the M4a representative surface. Batch 2 adds the remaining built-in picker leader actions and picker ex callbacks, including query-bearing :grep, named :Picker sources, marks/register sources, and picker resume state. Batch 3 adds every remaining Harpoon action and ex callback, host-owned cross-note <C-o>/<C-i> with count forwarding, and :marks/:delmarks/:jumps. Batch 5 leaves every fold and undo operation in Neovim and bridges only UndoTreeToggle, UndoTreeShow, and UndoTreeHide; the sidebar reads native undotree() data. The existing redraw provider forwards visible foldclosed(), foldclosedend(), and foldlevel() state beside extmarks, and the host mirrors those ranges into CM6 without another channel or timer. Cross-file navigation waits for active-note re-seeding and restores the stored cursor in both CM6 and Neovim. Once a picker opens, its modal receives keyboard input directly. Oil’s embedded editor is outside RPC key delegation by design: no RPC keydown listener is attached while Oil is active, fork interception is disabled, and all 16 Oil mappings execute natively without reaching or changing Neovim’s mirrored Markdown buffer. Lowercase ex callbacks are available through uppercase Neovim user commands plus start-of-command-line guarded <expr> abbreviations, so substitutions such as :%s/marks/x/ remain substitutions. Dispatch payloads carry a general count and argument field rather than only an action id. Bridge refresh and disconnect remove generated mappings, commands, abbreviations, and notification listeners before reinstalling.

M5 structural motions and Markdown text objects are Class A′ buffer-text behavior and do not cross the Obsidian feature bridge. The bundled companion installs buffer-local mappings backed by Neovim’s bundled markdown and markdown_inline treesitter parsers and removes them during companion teardown. M5b covers emphasis, inline code, math, strikethrough, links and wikilinks, fenced code blocks, nested blockquotes, callouts, HTML tags, table cells, and table rows in operator-pending and visual modes. Operators execute over an explicit bounded visual range rather than a cursor-moving callback. Neovim’s native it/at supplies tag matching, with the count consumed once to match the fork’s custom object. Highlight (i=/a=) remains unavailable under RPC because Neovim’s bundled Markdown grammar does not expose ==...== as a syntax node; the companion does not fake a treesitter range with delimiter scanning. The mirrored buffer receives the plugin’s textwidth; native gq and gw use Neovim’s stock Markdown ftplugin rather than a ported wrapping implementation.

Intermittent renderer crash when disconnecting the RPC backend (Fixed)

Status: fixed, and the cause was not what this entry described. The crash
was a leaked web-tree-sitter TreeCursor. getAllNodesOfType in
src/treesitter/js-api.ts allocated one per structural motion and never called
delete(), so it stayed in web-tree-sitter’s FinalizationRegistry — which
registers a cursor under its tree’s pointer, not its own. The CM6 bridge
frees that tree on the next re-parse, and GC then fired the finalizer against a
dangling pointer, corrupting the WASM allocator from a GC callback. That is why
it needed RPC traffic (which drives the re-parses and the allocation churn) and
fork editing (which leaked the cursors) in the same session.

Isolating the two halves of the original fix measured the leak at 5 of 8
container runs with no nodes retained, against 0 of 6 for the retaining walk
with the cursor freed. The reproducer spec that measured 24 of 46 now measures
0 segfaults in 16 runs (p ≈ 0.004 against its own 29% post-mitigation rate).
.ast-grep/rules/treesitter-handle-leak.yml gates the shape.

The deferred-teardown mitigation described below has been removed. It was
adopted on a measurement that is now known to have been confounded — both of
its arms contained the leak. With the leak fixed, the reproducer measures 0
segfaults in 16 runs in all three teardown shapes: the 3-second deferral, the
original synchronous qa!-and-wait that used to crash 24 of 46, and the
immediate non-blocking SIGTERM that now ships. 48 runs, 0 segfaults. Teardown
keeps the non-blocking shape on its own merits — disconnect returns immediately
instead of blocking for up to four seconds — but the 3-second delay that existed
only as a crash mitigation is gone.

The original description follows, since the reasoning it records is what the
evidence above corrects.

Disabling the Neovim backend can crash Obsidian’s renderer process. It is a
native SIGSEGV — a read of an unmapped page through what looks like a corrupted
V8 compressed pointer — so it appears as Obsidian’s window disappearing or
reloading, with no JavaScript error.

It requires RPC traffic and a disconnect in the same session. Measured in a
Linux CI container: 216 tests with the backend off produced none, 432
traffic-free connect/disconnect cycles produced none, and 2,400 requests without
a disconnect produced none, while a spec performing 14 connect-traffic-disconnect
cycles crashed 24 of 46 runs. Stubbing the extmark, float, buffer-line, and
cursor handlers did not change the rate, so it is not caused by processing
Neovim’s output.

Removing the synchronous qa!-and-wait from teardown reduces it about threefold
(7 of 38 runs versus 24 of 46, Fisher p = 0.002) and is shipped, but a residual
path remains: retaining the child process indefinitely still crashed 3 of 8 runs.
Memory, JS heap growth, DOM growth, msgpack recursion depth, Electron version,
and every container security and namespace setting are all excluded by
measurement.

Tree-sitter WASM handle lifetime was previously listed here as excluded. That
was wrong
, and it is the root cause recorded above. The arm that appeared to
exclude it neutralised this repository’s delete() calls but not
web-tree-sitter’s FinalizationRegistry, so it never achieved “nothing
freed” and never excluded anything.

Electron’s own guidance is that a renderer should not own a crash-prone child
process — UtilityProcess exists for exactly this, and spawning subprocesses is
documented as work to delegate to the main process. An Obsidian plugin has no
access to either, so the backend must spawn Neovim from the renderer.

Practical impact is narrower than the CI rate suggests. Building the workload up
one ingredient at a time showed that RPC work alone does not crash: 40 note
switches with editing, 40 structural-motion batches (]h, d]l, gqG), 1,200
requests, and even 14 connect/disconnect cycles each measured 0 segfaults in 8
runs
. It only reproduces when editing through the bundled fork is
interleaved with RPC work in the same session, which measured 3 of 8.

That is what a parity spec does — alternating the two engines fourteen times per
file is its purpose — and what a person does not. A session that enables the
backend and works stays on the measured-clean side. The risky pattern is
disabling the backend, editing with the bundled fork, re-enabling it, and
repeating. Enabling the backend is opt-in and desktop-only.

There is no in-process recovery: this is a renderer-process SIGSEGV, so the
plugin’s own code dies with the window and nothing of ours runs afterwards.
Electron’s answer is UtilityProcess, which a plugin cannot reach. What the
plugin does instead is leave a breadcrumb — a marker written before a real
connect or disconnect and cleared once it settles — so a restart that finds it
still set reports that the renderer died mid-switch rather than leaving the
crash unexplained. Notes are unaffected: the mirror is acwrite and Obsidian
owns the file.

Neovim popup-menu completion is not displayed in RPC mode (Fixed)

The attached UI requests ext_messages, ext_cmdline, and ext_popupmenu. M8a routes messages, M8b renders the external command line, and M8c renders popup-menu items, selection updates, and teardown. grid=-1 completion is anchored to the command line with byte-position conversion; insert completion uses reported grid cells and CM6 metrics. Grid drawing events remain intentionally discarded.

  • vim.ui.select() and vim.ui.input() now display their generic Neovim prompts, accept typed responses, invoke their callbacks, and support <Esc> cancellation.
  • Typing :, /, or ? displays Neovim’s command line and echoed input. With wildoptions=pum, command-line completion candidates and selection are visible; insert-mode completion is positioned by its grid coordinates.

Commands that the plugin issues over RPC are unaffected, because they never touch the command line.

Message severity is derived from Neovim’s message kind, which does not carry vim.notify()’s log level. Measured against Neovim 0.12.5: vim.notify(msg, ERROR) arrives as echoerr and is styled as an error, but vim.notify(msg, WARN) arrives as echomsg — indistinguishable from INFO — and is therefore shown as an informational Notice. Warning styling applies to Neovim’s own wmsg warnings. Recovering the level would require reading the message’s highlight attribute rather than its kind.

Fold persistence is unavailable in RPC mode

Neovim owns fold state while the RPC backend is connected. The plugin therefore does not restore persisted CM6 fold offsets into Neovim: doing so would introduce a second fold authority and stale offsets after edits. Fold persistence continues to work in bundled-fork and built-in Vim modes.

Uppercase cross-file mark motions are not bridged in RPC mode

Lowercase within-buffer mark motions such as 'a and `a remain Neovim-native. Uppercase marks A–Z are cross-file locations owned by the plugin, but the RPC backend reuses and renames one Neovim buffer for every active note, so Neovim’s global mark system cannot preserve their cross-note identity. Batch 3 bridges :marks and :delmarks only. Motions to uppercase marks remain deferred until a later host-motion adapter is added.

The mirror’s buffer-local buftype=acwrite prevents Neovim from writing the named vault file itself. Its buffer-scoped BufWriteCmd notifies the host, which invokes Obsidian’s editor:save-file command after line-event synchronization has made CM6 current, then clears Neovim’s modified flag. A buffer-scoped BufReadCmd makes :e and :e! re-seed from the current Obsidian document with line-event echo suppressed, so a stale disk copy cannot flow back through Neovim and overwrite the editor.

With Neovim configuration path empty, the backend runs the supplied Neovim binary and the user’s normal Neovim configuration as arbitrary code. This production default is deliberate. A configured absolute path instead starts Neovim under --clean, prepends that file’s directory to runtimepath, and loads only that init.lua with -u; this supports a smaller Obsidian-specific setup and avoids terminal-only plugins. Either configuration may use LuaJIT FFI to load native libraries and may read or write files outside the vault. No sandbox is provided. Vim Motions never downloads or installs Neovim itself. It can ask Neovim to install or update the plugins it writes configuration for, through Neovim’s own vim.pack, into Neovim’s data directory — but only on an explicit button press that first shows what will be fetched and what will be written. That is an explicitly user-requested install, which the Developer Policies allow; the clause about installing dependencies targets a plugin pulling in what it needs unasked. The fengari-only pluginAutoFetch path remains disconnected from this runtime, which is a separate boundary and still enforced by test/unit/rpc/plugin-autofetch-boundary.test.ts.

buildNeovimSpawnArgs() in src/rpc/neovim-connection.ts is the whole argv surface, and the only filesystem path it can carry is the user’s configured neovimConfigPath. test/unit/rpc/plugin-autofetch-boundary.test.ts asserts the complete argv, so any added runtimepath entry fails it, and separately asserts that no file under src/rpc/ imports the fetch or store modules or names their on-disk paths. This is risk R-5 in the design plan, held by a test rather than by an argument.

The visual selection is a decoration, not an Obsidian selection

Neovim’s visual selection is rendered as a CM6 decoration while CM6’s own selection stays the caret Neovim reports. Mirroring it into EditorSelection was measured and rejected: with a non-empty selection in the editor, Obsidian consumes the Escape keydown before the RPC delegation listener on contentDOM sees it, so visual mode could be entered and never left. Neovim stayed in v across <Esc>, clearing the DOM range did not help, and disabling the mirroring restored n.

The consequence is that Obsidian features which read the editor’s selection do not see a visual selection — Neovim owns the selection semantics in this mode, as it owns text and cursor. test/specs/rpc-visual-selection.e2e.ts pins both halves, so an attempt to “simplify” the decoration into a real selection fails on the caret assertion and on the Escape assertion rather than silently reintroducing the defect.

Blockwise selections become one range per row and their columns are byte offsets rather than display cells, so a block spanning rows of differing width is approximate in the same way as the rest of the bridge’s terminal-cell mapping.

Per-mode cursor shape does not follow Neovim unless the animated cursor is on (Fixed)

The fork now exposes setExternalCursorMode(), which setExternalVimMode() forwards to, so the cursor follows the backend’s mode with Animated cursor off. Two constraints on that export are load-bearing and were established by measurement, not preference:

  • It must not write cm.state.vim. That state is shared with the status bar and the mode tracker.
  • It must not redraw with a transaction. refreshExternalMode() uses requestMeasure, which is a measurement pass; a dispatched transaction disturbs the RPC composition input.

An earlier host-side attempt violated both and turned rpc-ime.e2e.ts and rpc-lifecycle.e2e.ts from 5 and 13 passing into 3 and 2 failures. Those two specs are the regression gate for any further cursor work, and neither is obviously cursor-related.

Two facts worth keeping for anyone touching this: the insert cursor is the native caret, not a fat cursor — measureCursor() computes showCursor = !insertMode || overwrite || shape !== 'bar', so a bar shape draws no element and caret-color is set instead, and a test must assert the absence of .cm-fat-cursor plus a non-transparent caret. And an external mode change produces no ViewUpdate of its own, so the fork keeps a registry of live BlockCursorPlugin instances to refresh; update() alone never sees it.

Only the active Markdown editor is mirrored

One Neovim buffer is created in NeovimDocumentSync.start() and renamed and reseeded on every active-leaf-change. A split pane therefore shows Obsidian’s own rendering of a note that Neovim is not tracking: keys go to whichever leaf is active, and the inactive pane is an ordinary input-inert CM6 editor.

This is deferred rather than pending, because it is an architectural change rather than a fix. The coupling, measured:

SiteAssumption
src/rpc/document-sync.ts15 references to a single this.buffer and 10 to a single this.editorView
src/rpc/companion.lua23 references to one mirror_buf — every structural motion, text object, fold alias, the BufWriteCmd/BufReadCmd pair and the decoration provider are bound to it
src/rpc/key-delegation.tsnvim_win_get_cursor(0) and nvim_win_set_cursor(0) throughout, so “the window” is implicit
cmdline, popupmenu, decorations, obsidian-feature-bridgeeach resolves the one active MarkdownView to position or apply against

A real implementation needs a buffer-per-leaf map with line events routed by buffer handle, the companion rebound per buffer rather than captured over one, and Neovim windows corresponding to Obsidian’s splits so window-relative calls mean something. Pane focus would have to drive nvim_set_current_win rather than a rename, which also removes the per-activation reseed and the didClose/didOpen churn it forces on a language server.

Until then the single-buffer invariant is what keeps the mirror safe: rpc-text-sync.e2e.ts asserts that Neovim’s previous mirror is never written into another note, which is the failure mode a partial multi-leaf implementation would reintroduce.

vim.lsp.enable() attaches to the mirror buffer only on the first activation (Fixed)

A native LSP workflow already crosses the bridge without new bridge code, and test/specs/rpc-lsp-capability.e2e.ts measures it against an in-process server: server-provided completion items render in the external popup menu, vim.lsp.buf.hover() renders as a float, and diagnostic virtual_text and underline render as CM6 decorations carrying Neovim’s own highlight groups. The mirror buffer is named with the note’s absolute vault path, so a server’s root resolution has a real path to work from.

The declarative attach path is nonetheless fragile. vim.lsp.enable() attaches on FileType and skips any buffer whose 'buftype' is already set. activateDocument() runs filetype detect before it applies buftype=acwrite, so the first activation attaches — but the mirror buffer is reused and renamed for every subsequent note, and by then buftype is acwrite, so every later FileType is skipped. Measured on Neovim 0.12.5: with buftype empty at FileType a client attaches, with buftype=acwrite none does, and vim.lsp.start() attaches to that same acwrite buffer. The spec pins both the gap and that vim.lsp.start() positive control.

Both halves are fixed. activateDocument() now detaches any attached client before renaming the buffer, which closes the old document, and clears 'buftype' so every activation looks like the first to Neovim’s attach rule; acwrite is restored once the content is in place. A client therefore attaches on every note activation, including one enabled later in the session, and the server’s document URI follows the note instead of going stale. test/specs/rpc-lsp-capability.e2e.ts asserts the didClose/didOpen pair by name and that a late vim.lsp.enable() attaches on the next activation while a bare filetype detect still does not — the latter is Neovim’s own rule and is what the buftype clear exists to work around.

The decoration bridge carries three extmark fields and drops the rest (Fixed)

ForwardedExtmark now also carries virt_lines/virt_lines_above, sign_text/sign_hl_group and line_hl_group, so diagnostic signs, diagnostic virtual_lines, 0.12 code lens, gitsigns and whole-line highlighting render. virt_lines becomes a CM6 block widget, line_hl_group a line decoration, and sign_text reaches the existing sign gutter — which had to become multi-source, because marks and the backend recompute independently and a single effect meant whichever dispatched last erased the other. number_hl_group and conceal are still dropped: the first needs a line-number renderer the gutter does not expose, and the second would fight Obsidian’s own Live Preview concealment.

test/specs/rpc-native-capability.e2e.ts pins each case, pairing every “does not render” assertion with a same-namespace, same-position virt_text positive control so the absence cannot be satisfied by forwarding being broken outright. The fields decide real features: diagnostic signs need sign_text, diagnostic virtual_lines and 0.12 code lens need virt_lines, and whole-line diagnostic or debugger highlighting needs line_hl_group.

Neovim plugin compatibility is decided by mechanism, not by plugin

Plugins run inside the user’s own Neovim, so loading is never the question. The bridge transports buffer coordinates and never reconstructs Neovim’s screen grid, which is attached only as a redraw clock. Anything a plugin expresses as persistent extmarks, virtual text, or floating windows crosses; anything it expresses in screen cells cannot.

screenpos(), nvim_win_text_height(), and the rest of the screen-cell query class are unbridgeable by construction, not deferred. Obsidian renders proportional Markdown typography, so there is no stable cell grid to answer such a query with, and D2 rules out grid reconstruction. matchadd() is unreachable for a different reason — matches are window-local and are not extmarks, so no buffer extmark query returns them. Ephemeral extmarks and legacy non-extmark highlights exist only during Neovim’s own redraw and are gone before any query.

flash.nvim is the measured case on the bridged side: its labels, jump, and floating prompt all render, and its own all-namespace extmarks are the oracle in rpc-decorations.e2e.ts and rpc-floats.e2e.ts. That is a class result, not a certification of any particular plugin.

The feature bridge is an allowlist, so it reaches only features this plugin registers. :ob/:obcommand is bridged alongside them and is the one endpoint that escapes that limit, executing any Obsidian command by ID — including commands owned by Obsidian itself or by other plugins, which an allowlist cannot enumerate. Lowercase :ob reaches it through the same start-of-command-line guarded abbreviation as the other lowercase commands, so :%s/ob/…/ remains a substitution.

Two RPC behaviours are verified by hand, not in CI

Everything else in this section is covered by test/specs/rpc-*.e2e.ts. Two are not, because the cost of automating them exceeds what they would catch:

BehaviourWhy it is manualHow to check
Automatic input-method switchingShells out to macism, im-select, fcitx5-remote or ibus, none of which a CI runner has. The mode seam that drives it is covered by rpc-lifecycle.e2e.ts.With an IM configured, connect the backend, enter insert in a Markdown note and confirm the input method switches; leave insert and confirm it reverts.
Per-mode animated cursor shapeRendered to a canvas; the resolved mode feeding it is covered by external-mode.test.ts and rpc-lifecycle.e2e.ts, but the drawn shape is pixels.Enable the animated cursor, connect the backend, and confirm the cursor takes the configured insert shape on i and returns to the normal shape on <Esc>.

Both were exercised by hand during implementation. A regression in either would surface as the seam reporting the wrong mode, which is asserted.

Bundled-engine features are not carried into RPC mode

Key delegation stands the fork down, so every feature implemented as a fork motion, action, or operator is inert unless the feature bridge or the companion re-provides it. The bridge covers Class-B host actions and the companion covers structural navigation and Markdown text objects; nothing else is re-provided. This follows the Class-A disposition in the design plan — RPC users install Neovim equivalents — but it had never been written down for users.

Neovim’s own behaviour applies instead for increment/decrement, subword motions, and flash f/F/t/T; gr is unmapped on 0.12.5, so replace-with-register simply does nothing. Yank-ring cycling, surround, and EasyMotion have no RPC implementation at all. Snippets are a partial case: the plugin’s JSON snippets are plain VS Code format, so LuaSnip loads them verbatim — verified by expanding date from the bundled global.json through a real LuaSnip and getting the resolved date rather than the literal variables — and the generated configuration installs LuaSnip, writes the bundled files out of main.js to disk, points it at those and the user’s snippet directory, and maps <Tab>/<S-Tab>. The Lua DSL does not carry across: its namespace and registration signature differ from LuaSnip’s, and its reactive f()/d() nodes call into Obsidian’s vault, which Neovim has no counterpart for. A JSON snippet’s context field is a Vim Motions extension LuaSnip ignores, so context-gated snippets become unconditional.

Because most of those features were modelled on a Neovim plugin, the settings tab can generate lua/vim_motions.lua beside the user’s Neovim configuration, translating the enabled ones into nvim-surround, dial.nvim, spider.nvim, yanky.nvim, flash.nvim, and mini.operators. It is inert until the user adds require('vim_motions') themselves, every block is wrapped in pcall(require, …) so the file loads with none of those plugins present, and it emits no install call — the plugin configures what the user already has and never fetches it, which is the same line §1.3 draws for pluginAutoFetch. It must land under lua/ to be requireable: a file beside init.lua is not, measured. Regeneration refuses to overwrite a file whose generated header is gone, so a user who edits it keeps their work. Neovim scans runtimepath for lua/ at startup, so the first generation is not visible to require until the next launch; this is not a practical limitation, because adding the require line means restarting anyway.

Three settings are projected onto the mirrored buffer rather than reimplemented, the way textwidth already was. Smart list continuation restores o/O bullet continuation, which the stock ftplugin does not provide: formatoptions=jtcqln omits o and r, so o on - item one yields hello. It cannot simply drop the ftplugin’s f comment flag, because that flag serves two behaviours at once — it is what gives gq its hanging indent while stopping o repeating the marker, and removing it made gq re-bullet every wrapped line, caught by rpc-structural-nav. The continuation form is therefore swapped in only for the duration of an o/O insert, through an expr mapping that returns the key so count, undo and dot-repeat stay native. Numbered lists remain out of reach: comments cannot increment a counter. Three further divergences from the bundled engine were measured in-product across 31 line shapes and are inherent to comments, not defects in the projection: a list inside a blockquote loses its marker (> - item continues as > , because the n:> leader consumes the quote and no non-nested leader is tried after it, and the same applies to > 1. and > - [ ]), a checkbox is not reproduced (- [ ] todo continues as - , since comments has no checkbox concept), and there is no fenced-code-block exclusion (o on a line inside ``` still continues, where the bundled engine suppresses it). Blockquote continuation itself agrees. A fourth divergence was an actual defect and is fixed: a tab-indented child continued with spaces, because Vim rebuilds a continued line’s indent from its column count rather than copying the original bytes, and Neovim’s Markdown ftplugin sets expandtab with tabstop=4. Indentation style is therefore projected too — Obsidian’s own useTab and tabSize become the mirror’s expandtab/tabstop/shiftwidth/softtabstop, which also governs >>, << and ==. Hardcoding expandtab=false is not the fix and was measured to invert the bug: with it, a space-indented vault has its four-space child rewritten to a tab. Yank highlight is reported by a TextYankPost notification and rendered by the host’s own component, so both solid and fade work; routing it through an extmark instead does not, because a yank changes no text, the decoration provider never re-runs, and the mark reaches CM6 only on a later redraw — long after the highlight expired.

A further group was assumed to be unaffected because it only renders, but in fact reads the fork’s event stream rather than bridge state: which-key and hint mode subscribe to the adapter’s vim-keypress/vim-command-done, yank highlight to vim-yank, input-method switching to vim-mode-change, and the animated cursor resolved its per-mode shape from adapter.state.vim, so the shape stayed on normal while Neovim was in insert.

The two mode-driven members of that group are now fixed. src/vim/external-mode.ts is a single seam the connected backend publishes Neovim’s mode into, mapped to the plugin’s vocabulary; the animated cursor consults it before the fork through resolveVimModeWithExternal, and the input-method watcher subscribes to it alongside the adapter event. It is published at connect as well as on every key, so the window between connecting and the first keystroke is covered, and cleared on disconnect so the fork regains ownership. Hint mode is also fixed, and by a smaller change than expected. Its overlay was never the problem: captureKeys binds to activeDocument in capture phase, ahead of the delegation listener on the editor, and stops propagation — so label keystrokes cannot reach Neovim. Only the trigger was missing, and the five hint* ex callbacks plus the hintMode leader mapping are ordinary registrations the feature bridge already knows how to install. The same is true of flash and EasyMotion, which share captureKeys; their triggers are deliberately left unbridged because s and <leader><leader> already mean something in Neovim.

The table-nav overlay also needs nothing: Obsidian’s keymap scope consumes its keys before the delegation listener on the editor sees them. Measured under a live connection — l moved the highlighted cell from column 0 to column 1 while Neovim’s cursor row stayed at 3 and the buffer was byte-identical. The earlier claim that it “requires the bundled engine” was wrong, and came from reading canActivate()’s forkAvailable as a test for the bundled engine when it is !isBuiltinVimEnabled(app) — that Obsidian’s own vim is off, which is true under RPC. Choosing raw table widget mode disables the overlay and leaves the table as ordinary Markdown for a Neovim table plugin to handle. That finding covered the overlay only, not cell editing, and the distinction mattered: with Obsidian’s cell editor open, TableNavController.installCellEscapeCapture ran a document-capture keydown handler that called stopImmediatePropagation(), destroying Escape before the delegation listener on the editor could forward it. Any Neovim command awaiting its second character — after Z, d, g, ", a count or an operator — therefore could not be cancelled, and RPC API requests stalled with no user action able to clear them. Measured: the same Z then Escape sequence recovers in 3 ms with no cell editor open and never recovers with one. The capture now skips stopImmediatePropagation() while a backend other than the bundled fork owns keys. The stall itself is normal Neovim behaviour and is not table-specific — it reproduces with no table involved — so it is not treated as a defect.

Which-key is not ported and will not be. It renders the plugin’s own leader registry from the fork’s key stream, and under RPC neither is authoritative — the bridge installs those bindings as real Neovim keymaps, and the user has their own besides, so a faithful port would display a keymap that is not in force. which-key.nvim draws in a floating window, which the float bridge already renders, and it shows the real keymap. Generated bindings therefore carry a readable desc prefixed Vim Motions: instead of an internal id, so they describe themselves in :map, :command and any which-key plugin. That covers the companion’s 18 structural motions and 26 Markdown text objects as well as the bridged actions; the fold-alias commands too. Leader prefixes are additionally registered as <Nop> mappings whose only purpose is a group label, so a which-key plugin renders a menu instead of a flat list. The leader key itself is deliberately never mapped — doing so would make it a complete binding and break every leader sequence, which is the property leaderGroupPrefixes is tested for. Position animation, gutters, cursor-line highlighting, and the status bar were never affected.

The plugin’s own Lua and vimrc config does not bind editor keys in RPC mode

.obsidian.init.lua and .obsidian.vimrc still load while RPC is connected, but a vim.keymap.set or map defined there targets the bundled fork, and key delegation stands the fork down through setKeyInterceptActive(true). Editor-context mappings from the plugin’s config therefore never fire; the Neovim equivalent belongs in the user’s own init.lua. Settings and host-rendered features are unaffected, because Obsidian still renders them. The Obsidian action mappings the feature bridge installs into Neovim are generated from the plugin’s registration data, so they follow the configured leader key but not arbitrary vim.keymap.set remaps.

Lua configuration

Status: Implemented.

The plugin provides a Lua 5.3 runtime via a browser-only version of fengari, absorbed into the monorepo at src/lib/fengari/ and converted to TypeScript ESM. Configuration is loaded from init.lua (or .obsidian.init.lua) and supports vim.keymap.set, vim.opt, vim.fn, vim.api, and more.

Known limitations:

Some vim.opt options not registered (Fixed)

Status: Fixed. All documented vim.opt options are now registered in both KNOWN_SET_OPTIONS and vim.defineOption. (#90)

12 plugin settings (yankring, yankhighlightmode, yankhighlightduration, undotree, undofile, undotreemaxnodes, jumplist, jumplistsize, foldawarenavigation, foldpersistence, harpoon, dial) were documented in the vim.opt table but never registered in KNOWN_SET_OPTIONS (the registry checked by the vim.opt proxy). Setting them via vim.opt or :set in vimrc produced "unknown vim.opt option" console warnings and had no effect. 10 of the 12 were also missing from the vimrc :set pathway (vim.defineOption); jumplist and jumplistsize already worked via :set but not via vim.opt. All 12 options now work identically across Settings UI, vimrc, and Lua.

Gutter settings ignored when set via vimrc or Lua (Fixed)

Status: Fixed. Gutter-related settings (number, relativenumber, numberwidth, linenumbermode, cursorline, cursorlineopt, signcolumn, statuscolumn, foldcolumn) now take effect when configured via .obsidian.vimrc or .obsidian.init.lua. (#101)

Two issues: (1) vimrc/Lua overrides were in-memory only — saveSettings() stripped them to preserve UI values, so they were lost on restart. CM6 gutter extensions are created at startup from persisted values, so the overrides never took effect. (2) reloadFeatures() never called gutter reconfiguration functions.

Fixed with a configOverrides persistence system: after vimrc/Lua loading, override values are captured in a configOverrides block in data.json. On next startup, these are merged on top of base settings before CM6 extensions are created. Also added gutter reconfiguration calls to reloadFeatures() for in-session changes.

The one-restart requirement originally recorded here no longer applies. Those in-session reconfiguration calls were themselves inert: iterateEditorViews() resolved editor.cm.cm — the CM5 compatibility adapter, which has no dispatch — so its guard rejected every leaf and the callback ran for no editor at all. Every gutter change therefore appeared to need a restart, because a restart was the only path that rebuilt the extensions. With the accessor corrected, test/specs/gutter-vimrc-lua.e2e.ts asserts the gutter is present in the editor DOM immediately after a Lua config load, with no restart.

Additionally, 27 settings that were previously UI-only are now configurable via vimrc/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.

preVimrcSettings shallow copy bug (Fixed)

Status: Fixed. preVimrcSettings snapshot at line 677 now deep-copies cursorShapes, modePrompts, and pickerKeymap. Previously, nested objects shared references with this.settings, causing Object.assign mutations in applySettingOverride to leak through to preVimrcSettings — making saveSettings() accidentally persist overridden values (e.g., cursor shapes set via set guicursor in vimrc were permanently saved to data.json).

Clipboard/textwidth falsely shown as “Set by vimrc” (Fixed)

Status: Fixed. The initial settings restoration at startup used onSettingOverride() for clipboard and textwidth, which wrote to vimrcOverrides even without a vimrc file. These settings appeared as “Set by vimrc” in the Settings UI and saveSettings() stripped them. Fixed by using direct side-effect calls (setClipboardOption, setTextwidth) that bypass the override pathway.

Expr mapping limitations

  • String expr mappings are not supported — vim.keymap.set('n', 'k', "v:count == 0 ? 'gk' : 'k'", { expr = true }) requires Vimscript expression evaluation which is not available. Use a Lua function callback instead.
  • Async expr callbacks are not supported — Expr callbacks run synchronously. Calling async APIs (e.g., vim.ob.fs.read) inside an expr callback will error. The callback must return a string immediately.
  • Expr results do not compose with pending operators — When an expr mapping runs during operator-pending mode (e.g., d followed by an expr-mapped key), the returned keys execute independently. The pending operator state is cleared before the callback runs. vim.v.operator is still readable inside the callback.
  • Count is not auto-forwarded to expr results — Typing 3K where K is expr-mapped and returns 'j' will execute j once, not three times. Include the count in the returned keys: return vim.v.count1 .. 'j'.

vim.v limitations

  • vim.v values in async callbacks are only reliable before the first yield — In async (non-expr) callbacks, vim.v.count, vim.v.register, and vim.v.operator reflect the values at callback start. After an async yield (vim.ob.fs.read, etc.), another callback may have overwritten these values. Read them into local variables at the start of your callback.
  • vim.v.count returns 0 outside callback context — Reading vim.v.count from a timer, autocmd, or vim.schedule callback returns 0, not the count from the most recent command.

vim.v deferred variables

The following vim.v variables are registered in the API and return default values, but are not yet populated by their respective subsystems. They will become active when the corresponding features gain Lua evaluation support:

  • vim.v.foldstart / vim.v.foldend / vim.v.foldlevel / vim.v.folddashes — fold text evaluation context variables. Currently return 0/''. Will be populated when a custom foldtext Lua callback is added (deferred to statuscolumn/foldtext v2). Note: foldlevel calculation requires counting enclosing folds, which is not currently tracked by CM6’s flat fold decoration system.
  • vim.v.lnum / vim.v.relnum / vim.v.virtnum — statuscolumn per-line rendering context variables. Currently return 0. Will be populated when the statuscolumn format string gains Lua expression evaluation (deferred to statuscolumn v2). The injection point (lineMarker() in statuscolumn.ts) is identified; the variables have no consumer until Lua expressions are supported in the format string.
  • vim.v.char — character typed during InsertCharPre autocmd. Currently writable but never set by the plugin. Will be populated when InsertCharPre is added to the supported autocmd events (requires a fork hook into insert-mode character input). Not in the current 19-event list.
  • vim.v.insertmode: Fixed. 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. Autocmd callbacks default to '' (no adapter context available).

Module snapshot and require()

require() searches a lua/ directory beside the configured init.lua first, then lua/ at the vault root. Every .lua file under those roots is read into memory when the configuration loads, and require() resolves from that snapshot. This is what lets a lazy require inside a keymap callback work: those callbacks cannot wait for a file read. Asynchronous reads remain the fallback, but only for callers that can wait (top-level configuration, autocommands, timers).

  • A lua/ directory beside a configuration outside the vault is desktop only — it is read through the filesystem rather than the vault adapter. On mobile it contributes nothing, which matches the out-of-vault configuration file itself being unreadable there. A configuration inside the vault has no such restriction.
  • Files created or edited after the configuration loads need a reload — the snapshot is a point-in-time copy. Requiring a module added since reports module '<name>' not present in the configuration snapshot, naming every path tried across both roots, rather than a generic “not found” that would be indistinguishable from a typo. Saving your main configuration file reloads it, as does the Vim Motions: Reload configuration command. There is no live watcher on lua/.
  • The snapshot refreshes on configuration reload and after vim.plugins.add() — a plugin fetch rebuilds it before your Lua resumes, so a freshly fetched plugin is immediately requirable.
  • Four resource limits apply, and breaches are reported rather than silent — 512 KiB per file, 16 MiB total, 2,048 files, and 32 directory levels. Anything skipped is named in the developer console with its reason, because a file dropped for exceeding a budget would otherwise present as a missing module. Directories beginning with . (including the plugin fetcher’s lua/.staging/) are not walked.
  • A module absent from the snapshot is unreachable from synchronous callers even if it exists on disk — this is the same reload boundary, seen from the other side. From a coroutine caller the asynchronous read still finds it.

Lua API limitations

  • nvim_buf_get_mark returns character offsets rather than byte offsets — Fixed for mark reads: line 1 / UTF-8 byte column 0, {0,0} when unset, and v:maxcol for a linewise end. Mark setters remain a separate deferred seam.
  • nvim_buf_get_option/nvim_buf_set_option/nvim_get_option/nvim_set_option are deprecated in Neovim (replaced by vim.bo/vim.o). Provided as compatibility aliases.
  • nvim_feedkeys only supports mode flags 'n' (noremap) and 'm'/'' (remap). Other flags ('t', 'i', 'x', '!') are ignored with a console warning.
  • nvim_echo maps to Obsidian’s Notice API — highlight groups in chunks are ignored (plain text only).
  • All buffer/window/tabpage handles must be 0 (current). Multi-buffer/multi-window operations are not supported.
  • nvim_replace_termcodes is an identity function: Fixed. Supported notation now produces Neovim key bytes, and nvim_feedkeys decodes them into the fork’s notation at the boundary. Unknown notation stays literal. from_part is ignored; unlike Neovim, <lt> expands with do_lt = true even when special = false.
  • vim.on_key observes pre-mapping input, unlike Neovim’s post-mapping hook. Mapped expansions and programmatic feedkeys are not separately observed; callback return values cannot discard keys; both callback arguments carry the same physical input. Closing this gap requires changing key processing itself. Registration, replacement, removal, namespace allocation, and teardown are implemented.
  • vim.iter has three extensions — rpop, count, and size are not present in Neovim 0.12. rpop aliases tail-removing pop; count drains the iterator; size does not consume and requires a list source, raising on function sources (including map-table and callable-table pipelines).
  • Global option compatibility values do not implement Neovim features — vim.o/vim.go read engine values first, then a shared shadow store, then defaults for eventignore, selection, cmdheight, columns, lines, cpo, and theme-derived background, otherwise nil. columns = 80 and lines = 24 are fallback values, not measured dimensions. operatorfunc now shares real read/write handling across vim.opt, vim.o, vim.go, and the global option API functions; g@ execution requires the bundled fork’s callback support.
  • Current-window compatibility only — nvim_win_call(0, fn) and nvim_buf_call(0, fn) call directly and propagate return values/errors without switching context. nvim_win_get_config(0) reports a non-floating window (relative = ''). vim.fn.getwininfo() returns one active-editor record with measured CM6 geometry and 1-based inclusive topline/botline, or an empty list for a non-zero handle or no active editor.
  • Synthetic window identity and dimensions — nvim_win_is_valid accepts only handle 0 as valid; width/height are CM6 viewport cells (0 without an editor), position is {0,0}, number is 1. win_getid() returns 0, also the failure value for invalid ordinals; winnr()/winnr('$') return 1 and winnr('#') returns 0. These do not add multi-window handles.
  • D4: interior-byte cursor writes deliberately differ from Neovim — nvim_win_set_cursor accepts byte columns but normalizes a position inside a UTF-8 character to that character’s first byte. Neovim preserves interior bytes; a UTF-16 host cannot represent them. Past-EOL clamping is native and unchanged. Do not describe this normalization as parity.
  • D5: text writes normalize interior bytes — a deviation, not parity — nvim_buf_set_text normalizes an interior start column down to the character’s first byte and an interior exclusive end column up to the boundary after the character. In contrast, nvim_buf_get_text honors interior bytes exactly by slicing the UTF-8 encoding. This asymmetry is intentional: fengari holds Lua strings as Uint8Array, so a split character is representable on the Lua side, while the host document is a JS UTF-16 string in which invalid UTF-8 has no representation. Reads clamp past-EOL columns; writes reject them. Empty ranges at exact character boundaries still insert.
  • D6: curswant reports the fork’s partial goal state — a deviation, not parity — getcurpos() returns five elements; the fifth reads sticky goal state from vim.lastHPos (not pixel-valued lastHSPos). A stored goal survives shorter lines and is converted to 1-based; the fork’s Infinity after $ maps to 2147483647. When lastHPos is -1, there is no pending goal, so the fallback uses the cursor’s display position: first cell of a wide character, last cell of a tab. This host-state mapping is not full Neovim goal-state parity.
  • Extmark interior-byte normalization is a deviation — Neovim preserves interior byte columns; CM6’s UTF-16 offsets cannot retain those byte remainders. Start columns normalize down and exclusive end_col up, following D5; getters return normalized byte columns, not the original interior bytes. Both endpoints accept 0..bytelen inclusive and reject out-of-range columns. For example, an end inside a multibyte character expands the highlight rather than collapsing it to an empty range. This is not parity.
  • Text, legacy-position and extmark columns use host units — Fixed through the shared adapter for nvim_buf_get_text/nvim_buf_set_text, getpos/getcurpos/setpos, and extmark setters/getters, subject to the deviations above. Extmark details now serialize as Lua tables with byte end_col and virt_text chunk pairs.
  • Only enumerated coordinate APIs have the byte contract — nvim_buf_set_mark, cursor, view save/restore, wincol, searchpos, and JS-backed strlen/strpart/stridx/strridx remain deferred. D1 governs the manifest’s 23 enumerated APIs, not the whole shim.
  • vim.bo.commentstring defaults to %% %s %% (Obsidian-native comment syntax) for all buffers. Treesitter-contextual commentstring (e.g., // %s inside a JS code block) is not implemented.
  • vim.is_callable does not detect callable tables with __call metamethods in the fengari sandbox. Only functions return true. (Note: rawget/rawset/rawequal are now available in the sandbox for Neovim compatibility, but vim.is_callable still uses a function-type check rather than rawget on the metatable.)
  • lockmarks ex command modifier is silently stripped in vim.cmd(). Marks are not preserved during buffer edits performed via lockmarks.
  • vim.plugins.add() supports automatic fetching from GitHub. Users can specify a repository (e.g., owner/repo) and optional branch/tag/commit. Archives are downloaded as tarballs and extracted to lua/. Requires pluginAutoFetch setting to be enabled.
  • require() supports init.lua fallback — matches Neovim’s module resolution by trying lua/name/init.lua if lua/name.lua is missing.
  • nvim_create_namespace returns unique integer IDs per namespace name (matching Neovim). Previously returned 0 for all namespaces.
  • Extmark limitations: nvim_buf_set_extmark supports virt_text, virt_text_pos ("overlay", "eol", "inline"), hl_group, end_row, end_col, hl_eol, priority, and id. Columns are bytes with the interior-byte normalization deviation above. sign_text, conceal, virt_lines, and line_hl_group remain deferred; buffer handle must be 0. Priority sorting is implemented but does not guarantee visual precedence over native CM6 decorations.
  • nvim_buf_get_text returns lines as a table (like nvim_buf_get_lines) with the range extracted. Buffer handle must be 0.
  • nvim_get_option_value/nvim_set_option_value support scope = "global" and scope = "local". Both map to the same option store (Obsidian has no true per-buffer option separation except for options explicitly handled by vim.bo).
  • vim.fn.getcharstr()/vim.fn.getchar() are async — they yield the Lua coroutine and resume when a key is pressed. A shared broker owns a single capture-phase listener and intercept lease across all waiters, so one keypress resolves exactly one waiter, in call order. Modifier-only keys (Shift, Ctrl, Alt, Meta) are ignored. Cannot be used in expr callbacks or snippet nodes. They remain subject to the coroutine runner’s 10-second await bound: waiting longer fails the call with async operation timed out. The listener and intercept lease are released when that happens, so a timeout no longer costs the user a keystroke, but a genuinely long human pause still cannot be awaited. Decoupling interactive waits from that bound is tracked in .sisyphus/plans/async-keymap-callbacks.md.
  • vim.fn.input() is async — opens an Obsidian modal with a text input field and yields until the user submits or cancels. Returns nil on cancel (Neovim returns empty string). Cannot be used in expr callbacks or snippet nodes.

Treesitter integration (vim.treesitter)

Status: Implemented (Phase 0–3 complete).

The plugin provides a vim.treesitter API backed by web-tree-sitter (WASM). Treesitter runs as a parallel parser alongside CM6’s Lezer — Lezer continues to power CM6’s native highlighting, folding, and indentation while treesitter is exposed through the Lua API for Neovim plugin compatibility. Markdown, Markdown inline, and HTML grammars are bundled; Lua configuration loading awaits runtime initialization and query preloading before evaluating user code.

Implemented: get_parser, get_string_parser, get_node, get_node_text, get_range, get_node_range, is_in_node_range, is_ancestor, node_contains, TSNode (31 methods), TSTree, LanguageTree (18 methods), query.parse, query.get, query.set, query.get_files, Query:iter_captures, Query:iter_matches, 8 built-in predicates with #not-*/#any-* generics, 4 built-in directives, language.register, language.get_lang, language.get_filetypes, language.add, language.inspect, query.add_predicate, query.add_directive.

Known limitations:

query.get() does not load .scm files from the vault (Fixed)

Status: Fixed. vim.treesitter.query.get(lang, query_name) now resolves query.set() overrides, then <vault>/lua/queries/{lang}/{name}.scm, then <vault>/lua/{plugin}/queries/{lang}/{name}.scm, then bundled textobjects queries for markdown, markdown_inline, and html. Auto-fetched plugin queries use lua/{owner}__{repo}/queries/. Resolution supports ;; extends and recursive ;; inherits: with optional (language) syntax, cycle detection, lazy compilation, and cache invalidation.

Correction to the previous compatibility claim: The missing file loader affected query-dependent integrations such as nvim-treesitter-textobjects. It did not block core mini.ai or mini.surround functionality: their Treesitter textobjects are opt-in, with use_nvim_treesitter = false by default. The earlier blanket claim about mini.ai was overstated even before this fix. Resolving this blocker does not establish complete compatibility with those plugins.

Query file limitations

  • Physical paths only — query.get_files() returns vault-relative physical paths. Bundled queries are TypeScript string constants without file paths and are omitted.
  • No live watcher — .scm edits require a configuration reload. The file snapshot is preloaded before user Lua runs; successful plugin fetches refresh it before Lua resumes.
  • Older cached plugins need re-fetching — the old downloader discarded .scm files. A configuration reload alone does not acquire those missing files.
  • Resource limits — 128 KiB per file, 4 MiB per snapshot, 512 KiB per combined query, 64 resolved sources, and 16 inheritance levels. Exceeding a limit logs a diagnostic and skips affected content.

Only Markdown and HTML grammars are bundled

vim.treesitter.language.add("javascript") (or any non-bundled grammar) fails with an error. The plan called for CDN-based grammar fetching on first use, but this is not yet implemented.

Workaround: Only "markdown" and "html" are available. Users cannot parse JavaScript, Python, or other languages embedded in code blocks.

To fix: Implement on-demand grammar fetching from a CDN (e.g., GitHub releases of tree-sitter grammar repos). Download the .wasm file, cache it in the plugin’s data directory via adapter.writeBinary(), and load it via Language.load(). The runtime already supports async grammar loading via the coroutine bridge.

#lua-match? predicate uses ECMAScript regex

The #lua-match? query predicate falls back to ECMAScript RegExp instead of Lua’s string.find pattern matching. Most .scm files use #match? (which correctly uses ECMAScript regex), but some Neovim-specific query files use #lua-match? with Lua pattern syntax (e.g., %w+ instead of \w+) that differs from regex.

Workaround: Use #match? with ECMAScript regex syntax in custom queries.

To fix: Implement Lua pattern → ECMAScript regex translation, or evaluate #lua-match? patterns through the fengari Lua string.find function. The latter is more correct but slower.

Stub functions: start(), stop(), foldexpr(), select(), inspect_tree()

These functions are present (calling them won’t error) but don’t perform their intended action:

  • vim.treesitter.start() / stop() — no-op. Treesitter-driven syntax highlighting via CM6 decorations is not implemented. The plugin uses Lezer-based highlighting natively.
  • vim.treesitter.foldexpr() — returns "0". Treesitter fold computation from folds.scm queries is not implemented. The plugin has its own fold system.
  • vim.treesitter.select() — no-op. Treesitter-based structural visual selection is not implemented.
  • vim.treesitter.inspect_tree() — no-op. The tree inspector debug UI is not implemented.

These are lower priority because the plugin provides equivalent native features (highlighting, folding) and the functions are rarely called by Neovim plugins (they’re Neovim UI/editor integration points, not plugin API).

TSNode handles go stale after a re-parse

Neovim’s contract is that a re-parse produces a new tree and leaves the old one valid until its owner deletes it, so Lua written against Neovim may hold a node across an edit. This plugin deletes the old tree on re-parse, and a TSNode is a light userdata holding an address into WASM linear memory. A node read after its tree was replaced therefore returns whatever now occupies that address.

Measured severity: stale data, not a crash — 80 nodes read after their tree was deleted, in each of 6 runs, with 0 segfaults. tree.delete() frees within the mapped heap rather than unmapping, so the read returns plausible-looking but wrong types and ranges.

Status: declined, not deferred. Both mechanisms that would fix it are unavailable. fengari arms its FinalizationRegistry only for full userdata, while nodes are light userdata on plain tables, so __gc never runs for them. Reference counting is not available either, because the fix would have to keep trees alive from node references, and that is a table-to-full-userdata conversion across all 31 node methods plus a fengari change — not a localized patch.

Note that simply dropping the delete() calls is not a safe alternative. web-tree-sitter registers every handle with its own FinalizationRegistry, so a dropped tree is still freed — just at a GC-determined moment instead of a known one, which is strictly harder to reason about. A cursor is worse: it registers holding its tree’s pointer, so a dropped cursor whose tree was already deleted frees a dangling pointer from a GC callback. That is the root cause of the renderer segfault recorded in test/flaky-inventory.md.

Workaround: re-acquire nodes after any edit rather than holding them across one, which is good practice against Neovim as well.

get_captures_at_pos() / get_captures_at_cursor() return empty

These functions still return empty tables. Named query loading is now available, but these helpers are not wired to evaluate a highlights query. Adding a highlights.scm file alone does not implement them.

Injection support is structural but not query-driven

The LanguageTree class supports child language trees and injection resolution, but its loadInjectionQuery() still compiles an empty query rather than consulting named query resolution. Loading injections.scm via query.get() does not automatically wire it into LanguageTree. Non-bundled language grammars remain unavailable.

To fix: Connect LanguageTree injection loading to named queries. Calling query.set() alone does not bridge this remaining integration gap.

Cross-note jump list

Status: Implemented.

The jump list tracks cursor positions across different notes, allowing you to navigate back and forth through your jump history using <C-o> and <C-i>. Jumps are recorded on cross-note navigation via gd/gD, picker file selection (all 14 sources), harpoon, oil, hint mode, ex commands (:e, :find, :tabnew, :buffer, :bfirst/:blast), structural buffer cycling (]b/[b), and Lua vim.cmd("e ..."). Standalone EasyMotion jumps are also recorded. Within-buffer jumps (G, gg, /, ?) are handled by the fork’s built-in jump list and delegate to the original jumpListWalk action.

The plugin-level jump list is cross-note only — it stores { filePath, line, ch } entries and only records when the source and destination files differ. The jumpListWalk action override peeks at the next entry: if it points to a different file, the override navigates cross-note; otherwise, it delegates to the fork’s within-buffer handler.

New settings: set jumplist / set nojumplist (boolean, default true), set jumplistsize=N (number, default 200). Persists across sessions via saveData(). Handles file rename/delete via vault.on('rename')/vault.on('delete').

Entries whose file cannot be resolved at navigation time (for example, a note deleted outside Obsidian, or stale persisted history) are skipped rather than pruned: <C-o>/<C-i> continue to the nearest valid entry in the direction of travel, dead entries do not consume the count, and the index is left unchanged when no valid destination remains.

:jumps ex command displays the jump list in a VimInfoModal.

Remaining limitations:

  • Cross-window (popout) jumps: The jump list tracks positions across notes within the main Obsidian window but does not yet support jumps between the main window and popout windows.
  • E2E test coverage for cross-note <C-o>/<C-i>: The jumpListWalk action override and within-buffer jump delegation are covered by passing E2E tests.
  • Cursor restoration uses a fixed settle delay: openJumpEntry() waits a hardcoded 50 ms after opening a leaf before restoring the saved cursor position, then verifies the active view matches the target path. When the editor takes longer than that to become ready — a cold file, a very large note, slow storage, or mobile — the guard causes cursor restoration to be skipped. The failure is contained rather than incorrect: the correct note is still opened and focused, and no cursor is ever written into the wrong file; only the saved line/column is not restored. A proper fix replaces the timer with a deterministic wait on editor readiness, which needs its own regression coverage.

Table cell vim modality

Status: Implemented.

The plugin uses Obsidian’s native table editor for cell editing. set tablewidget=native (default) uses the native cm-table-widget. Cell editing uses native TableCellEditor instances created by Obsidian’s editTableCell() method. Vim is injected into native cell editors via registerEditorExtension() propagation.

Three table editing modes are supported:

tableWidgetModeenableTableNavExperience
nativetrue (default)Full table-nav overlay with cell highlighting and structural commands
nativefalseNative table editor with vim cell editing and cross-cell h/j/k/l navigation, no nav overlay
raweitherRaw markdown tables — no widget rendering

A table-nav overlay activates when enableTableNav is on and tableWidgetMode is native. This mode allows cell navigation with h/j/k/l without entering the cell editor. Structural commands (o/O, dd, dc, J/K, H/L, I/A, =) are supported directly from the overlay. Pressing i/a/c/s or Enter enters the native cell editor. Escape exits table-nav.

Cross-cell motions (h/j/k/l crossing cell boundaries) are independent of the table-nav overlay. They activate whenever tableWidgetMode is native, regardless of enableTableNav. This allows using the native table editor with vim cell editing without the nav overlay intercepting every table entry.

  • Escape stays in cell: Escape in normal mode stays in the cell (matches Obsidian’s built-in vim behavior). Tab/Shift-Tab navigate between cells.
  • h/j/k/l cross-cell navigation: In normal mode, h/l at cell boundaries move to the adjacent cell (same row). j/k at row boundaries move to the same column in the next/previous data row (separator rows are skipped). j at the last data row or k at the header row exits the table. When the cursor is not at a cell boundary, h/j/k/l move the cursor within the cell as normal vim motions. Operator-pending (dj, yl) and visual mode motions stay within the cell.
  • Register sharing: Vim registers are shared between cell editors and the main editor via the fork’s vimGlobalState singleton. Yank in one cell, paste in another.
  • Status bar sync: The mode tracker reads vim mode from the cell editor’s CM6 instance when a cell editor is active, so the status bar reflects the cell editor’s mode (insert/normal/visual).
  • ir/ar table row text objects: ir selects inner row content (between first and last |, excluding pipes), ar selects the entire row including pipes. Works in raw markdown mode only — inside cell editors, the content doesn’t match TABLE_RE so these are no-ops (correct behavior).

Remaining limitations:

  • Cross-cell editing via Tab exits table-nav: Fixed. Tab in a cell editor now exits the cell and returns to table-nav on the next cell. Shift+Tab navigates to the previous cell. Tab/Shift+Tab also work directly in table-nav mode (equivalent to l/h). Count prefix works (3Tab moves 3 cells forward).

  • Count prefixes not supported: Fixed. 3j in table-nav mode now moves 3 rows. Digit keys are accumulated as a count prefix and consumed by the next navigation key. Count also works for h/l column navigation (2l moves 2 columns right).

  • Visual-cell selection not supported: Selecting multiple cells via visual mode is not implemented.

  • Dot-repeat for structural commands not supported: Fixed (table-nav mode only). . 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). The last structural action is cleared when entering cell edit mode, so vim’s native . handles text edits after cell editing. . only works while in table-nav mode — after exiting, vim’s native dot-repeat takes over.

  • Cross-cell word motions: Fixed (normal mode only). w/b/e/W/B/E/ge/gE at cell boundaries now jump to the adjacent cell in normal mode. The moveByWords motion is overridden with the same cross-boundary pattern as moveByLines/moveByCharacters. When the word motion result is null (can’t move further) or the cursor is stuck at a boundary, the override crosses to the next/previous cell via getNextCell. Visual mode and operator-pending mode (dw, cw) do not cross cell boundaries — each cell is a separate CM6 editor instance, so cross-cell selections and operations are not possible (same architectural constraint as visual-cell selection and cross-cell h/j/k/l in visual/operator-pending mode).

  • Count prefix on cross-cell motions: Fixed. 3j in a cell editor now crosses 3 cell boundaries. The moveByLines and moveByDisplayLines overrides loop repeat times through getCellBelow/getCellAbove, stopping at table boundaries.

  • Obsidian hotkeys blocked in table-nav mode: Fixed. Ctrl+P, Ctrl+S, and all other Obsidian hotkeys were silently swallowed while the table-nav overlay was active. 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. The keymap handler’s default case also consumed all unhandled keys. Fixed by parenting both scopes to app.scope, returning false for unhandled keys, and removing stopImmediatePropagation. (#146)

  • Under the Neovim backend, an owned table is presentational. tableWidgetMode: owned now renders while RPC is connected, but the nested cell editor is deliberately inert — contenteditable=false and never focused — so Neovim keeps owning text, keys, IME, floats and the command line. Two costs follow. Neovim’s own rendering does not appear inside the table: extmarks, flash labels, diagnostics, folds and signs all dispatch to the parent editor, whose copy of the range is block-replaced. And the active cell is not highlighted, because a block-replaced range cannot host a caret — under the bundled engine it is the focused child that keeps the parent’s selection parked there, and a presentational child cannot do that. Measured with Neovim on row 5, the parent’s head sits on row 3. Neovim’s cursor remains authoritative and edits land on the correct row. A CM6 → Neovim cursor push would not help: CodeMirror relocates the selection straight back, and no loop guard exists for that direction.

  • No caret is drawn inside a cell in tableWidgetMode: owned: Fixed in @saberzero1/codemirror-vim@6.4.4. The nested cell editor did draw a caret and did hold focus; the fork’s BlockCursorPlugin was hiding it. Its update() set display: none on every .cm-cursorLayer:not(.cm-vimCursorLayer) found in its own scrollDOM — correct for its own view, wrong for a separate EditorView mounted inside that scrollDOM, which querySelectorAll reaches because a subtree query does not stop at an editor boundary. The nested editor draws its caret with plain drawSelection(), having been built without the vim extension. The fork now skips layers whose closest('.cm-editor') is not its own view.dom. This also explains why every measurement of that caret reported 0x0: a hidden element has no bounding box, so each probe was measuring the consequence. Whether the parent’s cursor is reliably drawn outside a table in this mode is a separate, still-unresolved question — a DOM query reports none while the fork’s own layer counter reports one, and the instrumentation cannot yet distinguish the parent from Obsidian’s transient cell editor.

  • The caret inside a cell does not reflect vim mode in tableWidgetMode: owned. With the caret in a table the cell shows CodeMirror’s native drawSelection() caret — measured 1x19 in normal mode, insert mode, and back, identical in all three — rather than the fork’s mode-shaped cursor. So normal and insert are indistinguishable inside a cell, and the per-mode cursor shapes (cursorShapes) do not apply there. The nested editor is built deliberately without the vim extension so that one vim state keeps owning commands, registers and dot-repeat, and the fork’s effectiveVimState returns null without cm.state.vim, so that view can draw no vim cursor at all.
    A fork API was prototyped — a cursor plugin drawn in the nested view while reading mode from the parent’s adapter — and is not shipped: it rendered a block cursor (10x19) but the shape still did not follow mode, the native 1px caret was not hidden so two carets showed, and the plugin’s hand-written CmAdapter type does not satisfy the fork’s CodeMirror. Closing it needs the parent’s insertMode to be resolved correctly from the child’s update cycle, which the prototype did not achieve.

  • tableWidgetMode: raw is deprecated and will be removed. Despite the name it does not show Markdown source: it hides Obsidian’s widget with CSS while Obsidian still block-replaces the table’s range, so the table renders as nothing at all. Use Source mode to edit a table’s source. The value keeps working for now and emits a one-time notice; the legacy values that used to resolve to it (suppressTableWidget: true, tablewidget=always) now retarget to native, while an explicitly chosen raw is left alone.

  • tableWidgetMode: owned replaces the table renderer, not Obsidian’s cell editor: parking the cursor in a table still constructs a TableCellEditor, and while editMode.tableCell holds it a write to that one cell’s range does not land — writes to the header row, the separator row and the rest of the document all do. Obsidian builds it off-document (measured at cm.dom.isConnected === false, with focus still on the parent), so it is invisible and does not contest focus. Owned mode clears it from the same pass that maintains the nested editor, which restores writability; null is Obsidian’s own resting value for that field. A nested editor of our own is mounted into the table’s widget and takes focus when the cursor is inside it. A key router sends every keystroke to the parent’s vim, so normal and visual mode work there (dd, u, ., l, j, V, Vjd all measured). Insert-mode characters cannot be routed — handleKey returns undefined for them, because the fork leaves them to CodeMirror’s native input path — so they stay native to the nested editor and reach the parent as minimal single-region diffs, suppressed while an IME is composing. owned therefore supports normal, visual and insert mode on one vim state, one undo history and one document. Visual selections render, mirrored from the parent’s range — charwise from the parent’s own CodeMirror selection, linewise expanded from the fork’s vim.sel line numbers, and visual block as the enclosing charwise span, because the mirror currently forwards only the parent’s primary range. The fork already produces one range per row for visual block, and CodeMirror supports multiple ranges, so this is a gap in the mirror rather than a limit of either. Moving the cursor out of the table hands focus back to the parent editor, preserving the desired column and any count, and re-entering remounts and refocuses. Still missing before it could become the default, enumerated rather than implied:

  • Neither renderer re-aligns a cell’s contents, and neither is an HTML <table>. Idle tables now render as a marked-up grid — cell and delimiter spans carrying each column’s alignment as a class — but both surfaces show the table’s source characters in a monospace grid rather than a formatted table. That is deliberate: the cursor’s editor is CodeMirror text over the padded source, so a renderer free to lay cells out independently would shift the grid the instant the caret entered the table. A column marked ---: is therefore themeable but its text is not moved.

  • The table-nav overlay’s single keys are native-only. o, O, dd, J, K, H, L, I and A carry structural meanings only where the overlay auto-activates, which is native. In owned they keep their ordinary Vim meanings. The structural operations themselves are available there, through the :table* ex commands and their <leader>t bindings.

  • Visual block renders as the enclosing charwise span: Fixed. The parent carries one CM6 range per row — <C-v> over three rows yields three ranges holding the individual cell texts — and the mirror forwarded only the primary one, so a three-row block displayed as one row. Every range is now mirrored, with mainIndex preserved; the nested editor also needed EditorState.allowMultipleSelections, without which CodeMirror keeps only the first range however many are dispatched.

  • scrolloff=100 disables horizontal scrolling inside a table in native — issue #167 item 6. owned no longer reproduces it: the monospace grid sets white-space: pre, so the nested editor does not wrap and still scrolls — measured owned: 67 against native: 0 on a 1274px-wide window, both zero where the table fits. Ordinary horizontal scrolling (item 5) works in both; the nested editor follows the caret.

  • Obsidian widget features not reproduced: row buttons, column buttons, the column context menu, column and row drag-to-reorder, sort by column, mouse multi-cell selection, and alignment-aware rendering. Four former entries are done: the cell context menu opens (a right-click reaches Obsidian’s editor-menu pipeline), keyboard multi-cell selection renders a true rectangular block, click-to-place-cursor moves the parent’s cursor to the clicked cell, and malformed-table handling is defined rather than absent — a short row yields fewer cells and a long one is truncated for layout, both leaving the document byte-identical. Measured from the widget’s DOM classes and its 63 prototype methods; an earlier count of “nine” omitted drag-to-reorder, sort-by-column and the column menu.

  • Automatic realignment is lost. Obsidian’s native table editor realigns a table’s columns when it commits a cell edit — measured: editing a cell in a misaligned table pads the header to match. owned replaces that widget, so it does not happen. Realignment is available manually (:tablerealign, <Leader>tr, = in table-nav mode). Obsidian’s own formatter is reachable as TableEditor.rebuildTable(), which is pure and returns the formatted text, but only while its widget exists — so it is usable as a conformance oracle for our formatter, not as a dependency.

  • Column resizing is not among them, because Obsidian’s table widget does not have it: measured, there is no colgroup, no width style on any cell, and no method matching /resi|width/ — the grip handles move columns and rows rather than resizing them. So this is not a capability owned mode loses.

  • Snippets inside the owned surface are untested: Fixed. A prefix expands on Tab, Tab/Shift+Tab move between tabstops, Escape ends the session and leaves insert mode in one press, and the active tabstop is selected in the cell. Expansion did not work at all beforehand — wla<Tab> left the literal prefix and opened no session — because the router left an insert-mode Tab native to a nested editor that carries no snippet extension. Two gaps remain: a | or a newline in a snippet body damages the row, and inactive tabstops carry no highlight.

It falls back to native with a one-time notice under each of four conditions, every one a deliberate restriction rather than a missing feature: Obsidian’s own Vim key bindings are enabled (only the bundled engine has been measured against this surface); mobile (desktop only for now); the view is in Source mode or Reading view, since the decoration is Live-Preview-only — replacing table source in a mode meant to show source is issue #167 item 2; and the setting not being owned at all, because it is opt-in. The Neovim backend is no longer among them: the surface renders while RPC is connected, as a presentational table.

  • Per-view subsystems are duplicated into cell editors: Obsidian creates a separate CM6 EditorView per table cell, so every extension registered through registerEditorExtension() is instantiated again for each one-line cell document. Two are now withheld: the treesitter bridge — the most expensive, a WASM parser plus a retained tree per view — and the scrolloff enforcer, measured running 8 times inside a cell across two keystrokes. It cannot decide in its own create function, because a cell editor is constructed detached (measured at isConnected: false, with no .cm-table-widget ancestor), so the decision and the parse are deferred to the first update on a connected view. The four gutters (line numbers, sign column, fold column, status column) are built with CodeMirror’s facet-based gutter() API, which has no per-view condition; they remain installed and are hidden with CSS (.cm-table-widget .cm-gutters { display: none }) rather than excluded. The remaining subsystems cannot be gated this way, and the reason is structural rather than a backlog: four are facet-based gutters with no per-view condition (CSS-hidden instead), four are StateFields or fold facets that receive an EditorState and never see a view, one is a properties MutationObserver already inert in a cell by construction, and the cursor-line highlight does not render in a cell at all — measured, so there is nothing to withhold. The misleading skipInTableCells() wrapper, which named a guarantee it never provided across 14 call sites, has been deleted.
  • Visual block mode across cells: <C-v> operates within a single cell editor only.
  • Ex commands from cell editors: :w saves the main document (expected). :q closes the main tab (documented as expected behavior for v1).
  • Fine-grained undo: Cell edits are atomic in the main document’s undo stack. Individual keystrokes within a cell editor are not separately undoable in the main editor.
  • Animated cursor does not animate between cells: When the animated cursor is enabled, cross-cell navigation (h/j/k/l) snaps the cursor to the destination cell instead of smoothly animating the transition. Each cell editor has its own CursorController instance — crossing cells destroys one and creates another. A token-based position handoff seeds the new controller from the old controller’s screen position, but the global canvas (position: fixed on .app-container) renders behind table cell content due to CSS stacking contexts, so the transition animation is not visible. The native vim cursor (BlockCursorPlugin) is used as the steady-state renderer inside cells. Within a single cell, cursor movement animates normally via the native vim cursor’s blink/redraw cycle.
  • Which-key in embedded editors: Which-key popups work in table cell editors and textarea vim overlays (bundled vim mode only). The popup renders in the parent MarkdownView.contentEl (or .modal-container for textarea overlays in modals). User keymaps are fully available — the codemirror-vim keymap is global (defaultKeymap at module level). Embedded editors bypass the which-key show delay for immediate feedback. Settings hot-reload does not update active embedded editors (acceptable — they are short-lived); the next editor opened picks up updated config.
  • Viewport does not follow cursor in long tables: Fixed. In table-nav mode, navigating past the visible viewport left the highlighted cell off-screen. CM6 cannot scroll to positions inside opaque block widgets. Fixed with an EditorView.scrollHandler facet that intercepts scroll requests during table-nav and adjusts scrollDOM.scrollTop based on the highlighted cell’s DOM bounding rect. (#136)
  • Cross-cell cursor bounce-back on macOS: Fixed. scheduleCrossing() deferred focus changes raced with Obsidian’s table widget handlers on macOS Electron. Replaced MessageChannel with requestAnimationFrame to defer until after the full event dispatch cycle. (#136)
  • Escape does not return to table-nav after Enter cell entry: Fixed. The cell editor’s vim keydown observer consumed Escape before the Scope handler could intercept it, and the cell editor’s vim state had mode: null during initialization causing isVimIdle() to return false. Fixed with a capture-phase keydown listener and isCellVimIdle() that treats null mode as idle. (#136)
  • Viewport snaps to top of table on entry: Fixed. Entering table-nav on a long table scrolled the viewport to the table top. During the 80ms entry debounce, Obsidian’s native cell editor opened and scrolled the table into view. Fixed by locking scrollTop during the debounce window via a scroll event listener. (#136)
  • Table-nav ignores scrolloff setting: Fixed. Cell navigation used hardcoded 5px margins instead of the user’s scrolloff value. With scrolloff=999, the highlighted cell was not centered. Fixed by reading getScrolloffMargin() in both syncCursorToActiveCell and tableNavScrollHandler. (#136)
  • Parent cursor visible next to table during cell editing: Fixed. When entering cell edit from table-nav, the parent editor’s vim cursor appeared next to the table widget with oversized height. enterCellEdit() cleared cursor suppression immediately, but cell editor focus was deferred by 150ms. During the gap (and via stale requestMeasure callbacks after focus transfer), the parent’s BlockCursorPlugin rendered the unsuppressed cursor at the table-range position. Fixed by removing the premature clearCursorSuppressedForView + resumeAnimatedCursorForView from the nav→edit transition entirely — suppression stays active and is managed by the exit paths (exitCellEditToNav, exitTable). (#136)
  • Horizontal scrolling missing in table-nav: Fixed. Navigating to off-screen columns in wide tables left the highlighted cell outside the visible viewport. The table widget had overflow: visible which prevented any horizontal scrolling. Fixed by changing the widget to overflow-x: auto during table-nav mode and scrolling the widget element directly via scrollHighlightedCellIntoView(). (#167)
  • Cursor and state stuck after LP→source switch during table-nav: Switching from Live Preview to source mode while table-nav is active leaves setKeyInterceptActive(true), cursor suppression, and Obsidian Scopes stuck because the TableNavController ViewPlugin is never destroyed or updated during view reconfiguration. A window capture-phase keydown safety handler clears the key intercept flag on the first keypress (Escape), restoring vim key processing. Cursor suppression and stale Scopes are not automatically cleared — they require exiting the stale table-nav state manually. Full cleanup is blocked by the ViewPlugin not receiving lifecycle events during mode switches. (#167)

Fixed in native table editor migration

The following issues from the old custom table widget/cell editor implementation are resolved by the migration to Obsidian’s native table editor:

  • Cannot leave table downwards when on last line of document: Fixed. (#119)

  • Unhandled keys swallowed in cell selection mode: Fixed. (#120)

  • Which-key popups missing in cell selection mode: Fixed. (#120)

  • Picker focus stays on table widget: Fixed. (#120)

  • Table-nav key handler intercepts keys during modal/picker interaction: Fixed. (#120)

  • Modifier key combos consumed by vim during cell selection: Fixed. (#120)

  • Ex command dialog keys consumed by table-nav handler: Fixed. (#120)

  • Clicking outside table in embedded mode does not exit table-nav: Fixed. (#121)

  • Click-outside handler exits during modal interaction: Fixed. (#120)

  • Header-only tables enter table-nav: Fixed. (#121)

  • Stale table-nav state after document content replacement: Fixed. (#119, #120)

  • Cursor displacement when entering table-nav: Fixed. (#121)

  • Cell editor cursor shapes: Fixed. Native cell editors receive focus correctly — no .cm-focused dynamic stylesheet hack needed.

  • Wikilink cursor displacement in table cells: Fixed. The native editor handles wikilink rendering at the decoration layer, eliminating the cursor displacement that affected the old custom widget. (#121)

  • Pipe character (|) swallowed in table cells: Fixed. The native editor automatically escapes | as \| in the document source.

  • <br> conversion in cell editors: Fixed. The native editor handles <br> ↔ newline conversion automatically. The cellBrToNewline/cellNewlineToBr utilities are removed.

  • Visual mode highlighting in cell editors: Fixed. Charwise visual mode (v) in cell editors now shows selection highlighting via a CSSStyleSheet on document.adoptedStyleSheets that forces ::selection visibility in .cm-vimVisual:not(.cm-vimVisualLine) scoped to .vim-table-cell-editor. Linewise visual mode (V) uses the fork’s linewiseVisualHighlight ViewPlugin, which is focus-independent (checks vim.visualLine and vim.sel only). (#19)

  • Wikilink and formatting loss after cell edit: Fixed. Two issues: (1) the cell editor read the cell’s initial value from wrapper.textContent (the rendered DOM), which strips markdown syntax — [[note-a]] became note-a. Now reads raw markdown from the document source via getCellDocumentRange(). (2) On cell editor close, the cell content was restored as plain textContent without re-rendering. Now uses MarkdownRenderer.render() to restore proper inline formatting (wikilinks, bold, italic, code) after the editor is destroyed. (#19)

  • Tab cell navigation freezes editor: Fixed. Pressing Tab in insert mode inside an embedded table cell editor froze the editor — the cursor disappeared, vim mode got stuck in Insert mode, and Escape stopped working. Root cause: exitCellEdit() scheduled a 50ms refreshAfterOp() timer that was non-cancellable and had no state guard. When Tab called exitCellEdit() → enterCellEdit() synchronously, the deferred refresh fired while the new cell editor was active — removing its key handlers, potentially orphaning the editor DOM, and leaving the controller in an inconsistent state. Fixed with defense-in-depth: cancellable/deduplicated refresh timer, state guard in doRefreshAfterOp, skipRefresh parameter for Tab transitions, and belt-and-suspenders timer cancel in enterCellEdit. Tab at boundary cells (last cell + Tab, first cell + Shift-Tab) now returns to table-nav mode instead of silently re-entering the same cell. (#92)

  • Enter in cell editor breaks table structure: Fixed. Pressing Enter in insert mode inside an embedded table cell editor inserted a literal newline into the cell content. Upon exiting the table, the multi-line content was written back into the single-line table row, breaking the markdown table structure — the second line appeared outside the table. Fixed by converting newlines to <br> tags on cell editor close (cellNewlineToBr) and converting <br> tags back to newlines on cell editor open (cellBrToNewline). This preserves multi-line cell content using standard HTML <br> tags that Obsidian renders correctly within table cells. Both helpers are in table-utils.ts and handle all <br> variants (<br>, <br/>, <br />, case-insensitive). (#115)

  • Multiple tables per note — wrong table selected: Fixed. In embedded mode, 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. Root cause: findWidgetEl() in table-nav-controller.ts queried all .vim-table-rendered elements and returned the first match without considering which table the cursor was in. Fixed by adding a tableFrom parameter to findWidgetEl() and using CM6’s view.posAtDOM() to correlate each widget element with its document position, returning the nearest match. Additionally, exiting cell edit mode on the second table caused the cursor to jump back to the first table. Two sub-bugs: (1) activeEditTableRange was cleared before transactions that modify the document (closeCellEditor dispatch, tableRealign dispatch), causing buildDecorations to create a Decoration.replace widget for the active table and displacing the cursor — fixed by keeping activeEditTableRange set throughout the exit and refresh lifecycle, ensuring the StateField fast-path (prev.map(tr.changes)) fires during all document-changing dispatches. (2) After tableRealign, doRefreshAfterOp used Array.find() with a 200-position threshold to re-locate the table, which returned the first table within range rather than the closest — with two tables less than 200 positions apart, the first table always matched. Fixed by replacing Array.find with a nearest-match loop. (#117)

Undo tree visualization

Status: Implemented.

Shadow undo tree tracking branching history parallel to CM6’s linear undo stacks. g-/g+ navigate chronologically across all branches with ChangeSet-based buffer content restoration. :earlier/:later navigate by count, time (Ns/Nm/Nh/Nd), or save point (Nf). Sidebar view with DOM tree rendering, click/keyboard navigation, collapse/expand. Optional persistence via set undofile.

Known limitations:

  • ChangeSet composition for deep navigation: Navigation dispatches sequential addToHistory.of(false) transactions (one per tree node on the path). For very deep trees (50+ levels), this dispatches many transactions — imperceptible in practice but theoretically slower than single-transaction composition.
  • Persistence after external file modification: When undoFile is enabled and the file is modified outside Obsidian between sessions, persisted ChangeSets become invalid (document length mismatch). The tree structure is preserved for :undolist display, but navigation is disabled for that session. An Obsidian Notice is shown when a stale tree is detected (once per file per session). Detection uses file size comparison — same-length substitutions are not caught.
  • Per-file tree map memory: Fixed. Undo trees are now evicted from memory when all editors for a file are closed (on active-leaf-change). Dirty trees are persisted before eviction when undoFile is enabled. Persisted data on disk is not deleted — reopening the file restores from persistence or starts fresh.
  • No CM6 undo stack integration for cross-branch navigation: g+/g- use addToHistory.of(false) transactions, so pressing u after g- undoes the last user edit, not the navigation. This matches Neovim behavior.

Flash motions

Status: Working (Phase 1 + Phase 2 + Phase 3).

Flash-style enhanced f/F/t/T motions show labels on all visible matches when 2+ matches exist. Single-match cases autojump (stock Vim behavior preserved).

Known limitations:

  • Highlight rectangles hardcoded to 8×16px: Fixed. Highlight boxes now dynamically measure actual character dimensions via coordsAtPos(). CSS uses custom properties (--vim-motions-flash-w, --vim-motions-flash-h) with fallbacks for user CSS snippet compatibility. (#75)
  • Labels obscure matched text: Fixed. Labels are now positioned at the END of the matched text (after the last matched character), matching flash.nvim’s default after = true behavior. Match highlights render behind labels during the label phase. During label narrowing, match highlights persist for all targets while only labels narrow. (#75)
  • EasyMotion label shift: Labels for EasyMotion motions (word, char, line, search) now appear one character to the right of the target — after the target character instead of on top of it. This is a deliberate change matching the label-after-match positioning used by flash. The jump destination is unchanged.
  • Label vertical centering: Labels are vertically centered within the line height. On lines with taller fonts (headings), labels sit centered rather than top-aligned. Enable set labelmatchfontsize to scale label font to match the target line’s font size (e.g., larger labels on headings, matching Neovide-style behavior). Disabled by default.
  • Line motions target hidden formatting in Live Preview: Fixed. EasyMotion line motions (<leader><leader>j/k) now skip hidden markdown formatting (heading markers, bold/italic syntax) in Live Preview using skipHiddenPrefix() to find the first visually visible character via coordsAtPos(). (#79)
  • RTL (right-to-left) label positioning: Jump labels always appear to the right of the target, which is incorrect for RTL text. When Obsidian’s editor direction is set to RTL, labels should appear to the left. No existing jump-label implementation (flash.nvim, leap.nvim, vim-easymotion, VSCodeVim, AceJump) handles RTL — this is a universally unaddressed problem. Obsidian provides per-line RTL detection via dir attributes on .cm-line elements, and CM6 offers EditorView.textDirectionAt(pos). A proper fix requires per-target direction detection, flipped label placement, adjusted collision logic, and RTL testing infrastructure. Deferred as a separate feature. (#79)
  • No macro recording: Flash label selection is not recorded in macros. Macros capture the search character (f{char}) but not the label keypress. This is the same limitation as EasyMotion.
  • No dot-repeat for label selection: Clarified. Dot-repeat after df{char}{label} already works correctly — the fork stores the resolved position via _asyncMotionTarget and repeatLastEdit replays the operator to the same relative offset. The label UI does not re-appear during dot-repeat, which is correct vim behavior (Neovim’s . never re-shows interactive selection UI).
  • No remote operations: flash.nvim’s remote mode (yr{target} to yank at a distance without moving cursor) is not implemented. This requires vim state manipulation not available in the codemirror-vim fork.
  • No treesitter mode: flash.nvim’s treesitter node selection is not feasible — CM6 uses Lezer, not treesitter, and does not expose node selection APIs.
  • Count prefix ignored with labels: Fixed. 3f{char} now jumps directly to the 3rd match without showing labels. When the count exceeds available matches, the last match is used (Neovim parity). f{char} without a count prefix still shows labels for 2+ matches. Works in operator-pending mode (d3f{char}) and with t/T till motions.
  • Multi-line t column 0: Fixed. When t{char} finds a match at column 0, the “before” position now wraps to the last character of the previous line instead of excluding the target. Both flash (applyTillOffset) and EasyMotion (findTillTargets) are fixed. Matches at column 0 of line 0 (no previous line to wrap to) are still excluded.
  • Programmatic Escape: Flash labels can only be dismissed by DOM keyboard events (real keypresses). Programmatic Vim.handleKey(adapter, '<Esc>') does not reach the label handler. This mirrors the same limitation in EasyMotion.
  • Jump mode key binding is registration-time: Changing flashjumpkey at runtime requires a plugin reload or settings change that triggers reloadFeatures(). The key is bound via mapCommand during registration.
  • Jump mode overrides s: When enabled, s in normal mode triggers flash jump instead of substitute (cl). Visual mode s retains its default c mapping.
  • Jump mode s conflicts with surround cs/ys/ds: Fixed. The operator-prefix shadow resolver (see Operator-prefix key dispatch) automatically defers flash’s s motion when surround’s s<character> action is a partial match in operator-pending mode. cs", ds", ysiw" work correctly with flash jump enabled. The resolver uses a configurable timeout (operatorshadowtimeout, default 1000ms) — if no surround target character arrives within the window, the flash motion executes as fallback.
  • clever-f 5s timeout: The clever-f repeat detection uses a 5-second window. After 5 seconds, f{same-char} is treated as a new flash search.
  • Incremental jump check_jump: When pattern.length >= minPatternLength, typed characters are checked as labels first, then as search extensions. A character that matches both a label and a valid search continuation will jump rather than narrow. Below minPatternLength, all characters extend the search pattern.
  • skipChars same-line only: Label conflict skipping only checks the character immediately after each match on the same line. Matches at end-of-line do not conflict with any label.
  • Search mode post-commit only: Flash search labels appear AFTER committing a / or ? search with Enter, not during typing. This is a deliberate simplification from flash.nvim to avoid label-vs-search-char disambiguation. Labels auto-clear on any non-label key.
  • Search mode single match: Labels are only shown when 2+ matches exist. Single-match searches navigate directly without labels.
  • Search labels with */#: Word-under-cursor search (*/#) does not trigger flash search labels because it bypasses the search dialog.
  • Labels missing from top half of viewport with frontmatter scrolled off-screen: Fixed. In Live Preview mode, when frontmatter properties were collapsed into a widget and scrolled off-screen, flash labels only appeared in the bottom half of the viewport. Root cause: getVisibleRange() in src/easymotion/targets.ts used view.lineBlockAtHeight() to determine visible document lines, but CM6’s height map uses estimated heights for off-screen widgets — the collapsed frontmatter widget’s estimated height differed from its actual rendered height, causing coordsAtPos() to return null for targets near the viewport top. Fixed by using view.visibleRanges (which reflects actually-rendered document ranges) instead of lineBlockAtHeight. This also affected EasyMotion target scanning. (#114)

Operator-prefix key dispatch (timeoutlen)

Status: Implemented (operator-prefix shadow resolver).

The codemirror-vim fork implements an operator-prefix shadow resolver for disambiguating multi-key sequences that share a prefix with operator keys. When an operator is pending (c/d/y/etc.) and the next keystroke fully matches a motion but also partially matches an operatorPending action (e.g., surround’s s<character>), the resolver defers to the partial match — waiting for the next character to disambiguate. A configurable timeout (operatorshadowtimeout, default 1000ms matching Neovim’s timeoutlen) falls back to executing the deferred motion if no next key arrives.

This resolves the cs/ys/ds vs flash s conflict: when the user types c then s, the resolver waits for the surround target character instead of immediately executing the flash motion. The resolver supports arbitrary-length operator-shadow mappings.

Settings: set timeoutlen=1000 or set operatorshadowtimeout=1000 (vimrc), vim.opt.timeoutlen = 1000 or vim.opt.operatorshadowtimeout = 1000 (Lua), or Settings → Vim Motions → Vim engine → Operator shadow timeout. Aliases: ost, tm. Set to 0 for immediate execution (no deferral).

The fork also implements a backtracking deferral for user-registered keymaps that share a prefix with a shorter full match (e.g., user maps gc as an action while gcc is also mapped, or <Space><Space> while <Space><Space>h exists). When matchCommand finds both a full match and a longer partial, it defers the full match for operatorshadowtimeout ms, allowing the user to complete the longer sequence. If the next keystroke doesn’t extend the match, the deferred command backtracks and executes, replaying the leftover keys via doKeyToKey. The deferred command timer handles keyToKey type mappings (e.g., noremap <Space><Space> :buffers<CR>) by routing to doKeyToKey() instead of processCommand(). Four exclusion rules prevent false deferrals from built-in keymap collisions: _isDefault entries, special-key false prefix (< vs <leader>), motions extending non-motions (il vs i), and operatorPending actions without an active operator (s<char> vs s).

This means <leader>t mappings with longer partials (e.g., table nav <leader>tL) exhibit Neovim-correct timeoutlen behavior — the mapping fires after operatorshadowtimeout (default 1000ms) if no further key completes a longer match.

First zz/zt/zb press from a distant scroll position

Status: Known deviation, up to two display rows.

zz, zt and zb compute their target from charCoords, which resolves to CodeMirror’s coordsAtPos. For a line CodeMirror has not rendered — or has rendered only partially, which is the case when the cursor line sits well outside the current viewport — those coordinates are estimated from the average line height rather than measured. The scroll target inherits the estimate.

In practice the first press after a large jump can land up to about two display rows away from the Neovim-correct position, and a second press converges exactly, because it is computed from measured coordinates once the line is on screen. Measured on a 54-row viewport: zz on an 18-row wrapped line landed at scrollTop 2572 from a starting scroll of 3145, against the correct 2530 it reaches from a nearby starting position and on every subsequent press.

scrolloff does not cause this and does not change it; the same offset appears at scrolloff 0. The e2e coverage in test/specs/vim-builtin/z-commands.e2e.ts presses twice where it needs the converged value, so the artifact cannot mask a real regression.

Insert-mode surround dot-repeat (Fixed)

Status: Fixed. . after i<C-G>s{char}text<Esc> now replays the full surround + typed text. This exceeds both vim-surround and nvim-surround, where insert-mode surround dot-repeat is broken (nvim-surround #301). (#82)

The fork stores _surroundInsertChar and _surroundInsertNewline on lastInsertModeChanges during surroundInsert/surroundInsertNewline. During replay, replaySurroundAwareInsert (inside repeatLastEdit) strips the delimiter entry from changes[0], inserts pair.open, replays typed text, then inserts pair.close. Wrapped in cm.operation() for undo atomicity. Counted dot-repeat (2.) repeats the text inside one set of delimiters. Surround metadata is cleared in recordLastEdit (new session), onCursorActivity (cursor movement), and createInsertModeChanges (default init) to prevent cross-session leakage. Text typed before <C-G>s in the same insert session is not preserved in dot-repeat, matching canonical behavior.

Insert-mode surround macro recording (Fixed)

Status: Fixed. <C-g>s{char} keys typed during insert mode are now logged to the macro key buffer. The fork’s handleKeyInsertMode now calls logKey when a full insert-mode command is matched (match.type == 'full'), recording the complete key sequence (e.g., <C-g>s)) to the macro register. Previously, logKey was only called from handleKeyNonInsertMode.

Test coverage: test/specs/surround.e2e.ts — “macro register should contain <C-g>s keys after recording”.

Surround does nothing on doubled symmetric delimiters (Fixed)

Status: Fixed. ds$ on $$example$$ now correctly deletes the innermost $ pair to produce $example$. (#96)

findSurroundingQuotes() in the codemirror-vim fork used sequential pairing (i += 2) over collected quote positions. For $$example$$ with positions [0, 1, 9, 10], this created pairs (0,1) and (9,10) — the two adjacent $$ on each side — and the cursor between them matched neither. ds$, cs$, ds" on ""hi"", and other doubled symmetric surround characters all silently did nothing.

Fixed by replacing sequential pairing with cursor-expansion: search backward from cursor for the nearest quote (open), then forward for the next one (close). This handles both doubled delimiters and adjacent pairs ("hello" "world") correctly.

EasyMotion operator-pending mode

Status: Working via fork’s async motion support.

d<leader><leader>w{label} (delete to an EasyMotion target) works natively through the codemirror-vim fork’s async motion system. EasyMotion motions are registered via defineMotion and return a Promise<Pos>. The fork’s evalInput resolves the promise and applies the pending operator (d, c, y) to the resulting position.

Visual mode (v + easymotion) also works — the fork updates the visual selection head/anchor when an async motion resolves during visual mode.

Remaining limitations:

  • Dot-repeat (.) does not replay operator-pending easymotion operations — Fixed. The fork now stores the resolved async motion position as a relative offset in lastEditInputState._asyncMotionTarget. During dot-repeat, repeatLastEdit applies the operator with the stored offset instead of re-executing the async motion overlay.
  • Char-based easymotions (f, F, s, t, T) in operator-pending mode require an intermediate search-character keypress which adds complexity to the async flow
  • Capital letter (Shift+key) search not working — Fixed. waitForKey() resolved on the Shift keydown event before the actual character arrived. The modifier-key guard (e.key.length !== 1) now suppresses modifier-only keys, matching waitForLabel()’s existing pattern. (#84)
  • Inclusive motions (f, t, e) exclude the target character in operator-pending mode — Fixed. EasyMotion motions were registered with empty motionArgs, so the fork treated all motions as exclusive. Added per-motion motionArgs: { inclusive: true } matching native Vim semantics. (#109)
  • EasyMotion line motions (j/k) operate characterwise instead of linewise in operator-pending mode — Fixed. Added motionArgs: { linewise: true } to both easyMotionLine and easyMotionLineBack definitions. d<leader><leader>j{label} now deletes full lines (linewise), matching native Vim dj semantics.
  • EasyMotion forward motions do not set motionArgs.forward — Fixed. All directional EasyMotion motions now set motionArgs.forward (true for forward, false for backward), enabling the fork’s clipToLine function for forward cross-line operations.
  • EXTRA_DEFS bidirectional motions lack motionArgs — Fixed. easyMotionBdEndWord, easyMotionBdEndWORD, easyMotionBdTill now set inclusive: true, and easyMotionBdLine sets linewise: true via Object.assign(motionArgs, defArgs) inside the motion function body. This bypasses the mapCommand limitation — the motion mutates the received motionArgs directly, so operators apply correctly when invoked via Lua vim.keymap.set remapping.
  • easyMotionRepeat uses cm.setCursor() directly — Fixed. easyMotionRepeat is now registered as a defineMotion (was defineAction) with a mapCommand binding at <leader><leader>.. The motion function inherits motionArgs from the last executed EasyMotion motion via Object.assign, so operators like d, c, y apply correctly with the proper linewise/inclusive/forward flags.

Test coverage: test/specs/easymotion-comprehensive.e2e.ts validates d/c/y + easymotion flows, capital letter char search, linewise line motions (d+j, y+j, d+k), inclusive/exclusive motion behavior, and operator-pending repeat (d+<leader><leader>. with motionArgs inheritance).

EasyMotion labels in Live Preview

EasyMotion target scanning uses cm.getLine() which returns raw document text, including markdown syntax hidden by Live Preview (e.g., the URL in [text](url), formatting marks like **). Targets inside hidden text are filtered out by filterVisibleTargets() in src/easymotion/overlay.ts, which calls coordsAtPos() for each target and deduplicates positions that resolve to the same pixel coordinates (within 2px tolerance). When text is hidden by a replace decoration, all offsets within the hidden range map to the decoration boundary, producing duplicate coordinates.

This approach is decoration-source-agnostic — it works for any type of hidden text (links, formatting, embeds, third-party plugins) without needing to query specific decoration sets. The tradeoff is that two genuinely distinct targets at nearly identical pixel positions (e.g., adjacent zero-width characters) would be deduplicated. In practice, this does not occur with normal text.

Label collision detection in renderLabels() ensures that labels for nearby visible targets do not overlap. When a new label’s bounding box intersects a previously placed label, it is offset vertically below it. Label dimensions are estimated from the CSS (14px monospace font, 1px 3px padding).

Bracket text objects outside the pair

i(/i{/i[/i< (and their b/B aliases) search forward for the next pair when the cursor is not already inside one, matching Neovim (:h v_i(: “when the cursor is not inside a () block, find the next ’(’”). The search is not limited to the current line — di( on a line above the pair deletes the pair’s contents further down. It only looks forward: with the cursor past the last pair in the document, the object matches nothing and the operator is a no-op.

Until #178 this fallback ran only for the a variant, so da( worked from outside the pair while di( silently did nothing.

Smart asterisk disambiguation

i* tries **bold** first, then falls back to *italic*. In the case of ***bold italic***, the ** pair is always matched first, making it impossible to select only the italic portion with i*. Use i_ for underscore italic as a workaround.

Smart dollar disambiguation

i$/a$ tries $$ (block math) first, then falls back to $ (inline math). This matches the same pattern as i*/a* (tries ** bold first, falls back to * italic). For $$ a + b = c $$, the $$ pair is matched and da$ deletes the entire expression. For $x + y$, the $ pair is matched.

In the case of nested $ inside $$ (e.g. $$ $inner$ $$), the $$ pair is always matched first, making it impossible to select only the inner $...$ portion with i$. This mirrors the same limitation as the smart asterisk — use a different approach to select the inner math if needed.

Multi-line delimiter scan limit

Multi-line text objects (createMultiLineDelimiterTextObject) scan a configurable number of lines in each direction from the cursor (default: 20). The limit can be changed in Settings → Vim Motions → Multi-line text object scan range (5–200 lines). Bold, italic, or other delimited content spanning more than twice the configured limit will not be found if the cursor is far from the opening delimiter.

This limit exists for performance — scanning the entire document on every keystroke would cause latency.

Multi-line delimiter nesting

The multi-line text object scanner uses a simple forward/backward search for the nearest delimiter. It has no nesting awareness. Overlapping or nested delimiters across lines (e.g., bold inside italic spanning multiple lines) may produce incorrect selections.

Delimiters inside fenced code blocks are excluded from the scan — the scanner skips lines within ``` fences. Indented code blocks and inline code are not excluded. Fenced code blocks inside blockquotes (> ```) are now detected — findFenceLines matches fences with blockquote prefixes (/^(?:>\s*)*```/) and ensures open/close fences have matching blockquote depth.

Table navigation and editing

]|/[| (or ]c/[c) navigate horizontally between table cells. ]r/[r navigate vertically to the same column in adjacent rows (skipping separator rows). i|/a| text objects operate on individual cells — di| deletes cell content, ci| changes it, vi| selects it.

:tablerealign (or <Leader>tr) reformats a table so all columns have uniform width, respecting :---/---:/:---: alignment markers in separator rows.

Auto-format: typing | in insert mode on a table line triggers automatic column realignment — replaced with format-on-exit. Tables are now automatically realigned when the cursor leaves the table range after editing. No formatting happens mid-edit, so the cursor stays where you expect it. Typing || on a new line within a table generates a separator row matching the header’s column count. Manual realignment is available via <Leader>tr or :tablerealign. (#66, #67)

Typing | moves cursor to the left of | — Fixed. The mid-edit | interception that caused cursor jumps has been removed. (#66)

Escaped \| characters treated as cell boundaries during editing — Fixed. Escaped pipes, wikilinks ([[page|alias]]), and other |-containing inline syntax are no longer mishandled during table editing because the auto-format no longer runs mid-edit. In native mode, the native editor automatically escapes | as \| in the document source. (#67)

The following are intentionally not implemented:

  • j/k column tracking: Fixed. h/j/k/l now cross cell boundaries in native table cell editors via defineMotion overrides. The overrides delegate to the originals outside table cells, preserving stock vim behavior.
  • Tab/Shift-Tab: These conflict with Obsidian’s built-in table Tab handling and insert-mode tab completion.

Table widget in Live Preview

The plugin uses Obsidian’s native table editor in Live Preview. Two rendering modes are available via set tablewidget:

  • native (default): Uses Obsidian’s built-in cm-table-widget. Vim is injected into cell editors via registerEditorExtension(). The native editor handles wikilinks, pipe escaping, cursor positioning, and <br> conversion automatically. Cross-cell h/j/k/l navigation is always active in native mode, independent of the tablenav setting.

  • raw: Hides the widget. In Live Preview this renders the table as nothing at all — see the measured subsection below; it does not fall back to showing markdown source. Use Source mode for source-style table editing instead.

  • owned (experimental, display only): the plugin block-replaces the table with its own renderer at the highest CodeMirror precedence, which removes Obsidian’s widget from the DOM entirely. Editing inside the table is not supported yet. The renderer is a passive element, and a block-replaced range cannot host a caret: measured, coordsAtPos collapses every offset in the range to the widget’s edges, the selection relocates on its own, vertical motion treats the whole table as one display line, and insert-mode typing lands outside the table as a stray line. Rendering is therefore correct and editing is not. Use native to edit. Gated to desktop, Live Preview, the bundled engine, and a disconnected Neovim backend; each blocker falls back to native and says so once.

Old values (off, cursor, always, embedded) are automatically migrated to native or raw.

Cursor disappears when entering a table in source mode or raw mode: Fixed. The mainEditorTableCursorGuard suppressed the vim cursor whenever the cursor entered a text range matching table syntax (lines starting with |), regardless of whether a native table widget was actually visible. In source mode (no .cm-table-widget elements) and raw mode (widgets hidden via display: none), the cursor was suppressed with no alternative cursor shown. Fixed by adding a hasVisibleTableWidget() check that verifies at least one .cm-table-widget element with a non-null offsetParent exists before suppressing the cursor. This also short-circuits the findTableRanges() document scan when no visible widgets exist. This same root cause made table navigation with enableTableNav=false appear broken — cursor movement worked but the invisible cursor made it seem like j/k/↓/^N had no effect. (#132, #136)

Table manipulation commands (<Leader>t prefix and ex commands like :tablerowafter) call Obsidian commands via executeCommandById. In native mode, the native table widget is present and these commands work as expected.

set tablewidget=raw does not accept typed text inside a table in Live Preview

raw mode is implemented purely in CSS — body.vim-motions-raw-table .cm-editor .cm-table-widget { display: none } in styles.css. That hides the widget’s DOM but leaves Obsidian’s own table decoration in the CodeMirror state, so the document model still treats the table range as replaced. With the cursor reported inside a cell, a typed character lands at the end of the document instead.

Measured against the same five-line fixture with the cursor at offset 14, inside |abc|def|, entering insert mode and typing one character:

Render modetablewidgetResult
Live Previewrawcharacter appended at offset 36, the end of the document
Sourceraw|abc|defz| — correct
Live Previewnative|abc| defz | — correct cell, widget realigns the row

native in Live Preview and raw in Source mode both behave correctly with the identical fixture and cursor, which is what isolates the failure to raw in Live Preview rather than to Live Preview or to the measurement. No snippet is involved; this is ordinary typing. Discovered while checking whether raw was a workaround for the snippet-tabstop case above — it is not. A real fix has to suppress the decoration rather than hide the element.

The table is also invisible, not merely uneditable. Measured separately: with raw active the widget’s computed display is none and no .cm-line in the table’s range contains a pipe — the four source lines produce no rendered line at all, because Obsidian block-replaces them and hiding the widget removes the only rendering of them. So raw in Live Preview yields an invisible table that j/k still navigate, since the document itself is untouched. Source mode is the supported way to edit table source; raw is retained only because its j/k behaviour is covered by #136 regression tests.

The “suppress the decoration rather than hide the element” fix is now measured as achievable: a Prec.highest block-replace decoration supplied from a StateField removes Obsidian’s table widget from the DOM entirely. That is the foundation of the planned owned table renderer, which is also what will let raw be retired.

Vimrc soft-reload

Vimrc maps and settings are soft-reloaded when the vimrc file is modified — changes to nmap, set, and other map/setting commands take effect without reloading the plugin. The plugin watches the vimrc file via vault.on('modify') and re-applies maps and settings on change.

Limitation: exmap definitions only parsed during initial load (Fixed). exmap definitions are now soft-reloaded — softReloadVimrc() calls applyVimrcCommands() which processes exmap entries, and vim.defineEx() replaces existing handlers. Adding, modifying, replacing, or removing exmap entries takes effect on save. The fork’s undefineEx() API cleans up stale handlers — exmap names are tracked per vimrc load and unregistered before re-applying on soft-reload.

Config file resolution

The plugin searches the vault root for config files using a fallback chain (first match wins):

Vimrc: vimrc, .vimrc, init.vim, .init.vim, obsidian.vimrc, obsidian.vim, .obsidian.vimrc, .obsidian.vim

Lua: init.lua, .init.lua, obsidian.init.lua, .obsidian.init.lua, obsidian.lua

Non-dotfile names are preferred because Obsidian Sync skips dotfiles. The .obsidian.* variants are last in the chain for backward compatibility.

A custom path can be set via Settings → Vim Motions → Vimrc & key bindings → Custom vimrc path (or Custom init.lua path). When set, the custom path is used directly and the fallback chain is skipped. The setting provides file-suggest autocompletion. The settings UI shows which file is currently in use (“Currently using: {path}”) or a not-found warning for invalid custom paths. (#34)

External paths (desktop only): Custom paths can be absolute filesystem paths (e.g. ~/.config/obsidian/init.lua, C:\Users\<you>\.config\obsidian\vimrc). Paths starting with /, ~, or a Windows drive letter are read directly from the filesystem via window.require('fs/promises') instead of app.vault.adapter.read(). Tilde (~) is expanded to os.homedir(). This enables sharing a single config file across multiple vaults. On mobile, absolute paths are not supported — the plugin falls back to vault-relative paths only. (#51)

Changing the custom path in settings triggers reloadFeatures() (the path is in RELOAD_KEYS), but a full vimrc re-parse requires reloading the plugin — the same limitation as editing the vimrc file itself.

Configuration commands are desktop only

Vim Motions: Open configuration in default editor and Vim Motions: Open configuration directory in system explorer are both hidden on mobile. The two have different reasons, and only one of them is a platform limit.

Open configuration directory in system explorer cannot work on mobile. Obsidian’s App.showInFolder() wraps its entire body in an isDesktopApp check and has no mobile branch, so calling it on mobile is a silent no-op — not an error, and not something the plugin can detect from the return value, since it returns void. Underneath it resolves to Electron’s shell.showItemInFolder, which does not exist on iOS or Android, and neither Obsidian nor Capacitor exposes a “reveal this file in the system file manager” API on either platform. There is nothing to fall back to, so the command is hidden rather than offered as a no-op. (#182)

Open configuration in default editor is desktop-only by the plugin’s choice, not by platform constraint. App.openWithDefaultApp() does have a mobile branch — it calls CapacitorAdapter.open() and surfaces a notice on failure. The command stays gated because the main reason to open a configuration externally is an out-of-vault file, and out-of-vault paths cannot be read on mobile at all (see External paths above); a mobile user would be left with a command that only ever works for vault-relative configurations that Obsidian can already open.

On desktop, both commands handle vault-relative and out-of-vault configurations. Neither Obsidian API accepts an absolute path — both join their argument onto the vault base path — so absolute paths are routed through Electron’s shell.openPath / shell.showItemInFolder instead.

Config load notifications

On startup, the plugin shows an Obsidian Notice when vimrc or init.lua files are loaded. The notification behavior depends on the configuration mode and file state:

ConditionNotificationSuppressible
File loaded successfully (N commands)"loaded N command(s) from {path}"Yes
File loaded but empty (0 commands)"{path} loaded but contained no commands"Yes
File not found in single mode (lua or vimrc)"not found (searched {path})"No
Both files missing in dual mode (lua-vimrc)"no config files found (searched {vimrcPath}, {luaPath})"Yes
Lua syntax/runtime error"error loading {path}: {error}"No

“Not found” in single mode (configMode is lua or vimrc) always shows because the user explicitly chose that mode but has no matching file — this indicates a misconfiguration. “Not found” in dual mode (lua-vimrc) is suppressible because having neither file is a valid default state.

Notifications can be suppressed via Settings → Vim Motions → Vimrc & key bindings → Show config load notifications (default: on). Error notifications and single-mode “not found” warnings always show regardless of this setting.

Vim engine settings

Vim engine settings (clipboard, tabstop, shiftwidth, expandtab, insertmodeescape, insertmodeescapetimeout, textwidth) changed via Settings → Vim Motions → Vim engine now take effect immediately — each setting’s onChange handler calls vim.setOption() to push the value to the vim engine in addition to persisting it to disk. Previously, these settings only saved to disk and required an Obsidian reload to take effect (the vimrc code path always worked because it called vim.setOption() directly). (#39)

All vim engine settings (clipboard, tabstop, shiftwidth, expandtab, pcre, insertmodeescape, insertmodeescapetimeout, operatorshadowtimeout, textwidth) are now re-applied on plugin load from saved settings. Previously, only clipboard, textwidth, and pcre were synced at startup — the remaining six settings (insertmodeescape, insertmodeescapetimeout, operatorshadowtimeout, tabstop, shiftwidth, expandtab) were only pushed to the vim engine when the user actively changed them in the Settings UI or when a vimrc/Lua config file set them. Restarting Obsidian would lose the vim engine state even though the setting was saved to disk. This was the root cause of insert escape sequences configured via the Settings UI not working on mobile (iPad with Magic Keyboard). (#56, #125)

On Obsidian 1.13+, the declarative settings system (via setControlValue) now forwards vim engine setting changes to vim.setOption() in addition to persisting them. Previously, only the pre-1.13 imperative settings tab called vim.setOption() on change — the post-1.13 declarative path only updated this.plugin.settings[key], so changing these settings on newer Obsidian versions had no runtime effect until restart. (#125)

Settings fields for vim engine options lock after typing on iPad — Fixed. The insert mode escape field (and other vim engine settings) became greyed out and unresponsive after typing a single character on iPad with Magic Keyboard. vim.setOption() in the onChange handler triggered notify → onSettingOverride, re-adding the key to vimrcOverrides after clearSettingOverride had already removed it. refreshDomState then saw the override and disabled the field. Additionally, the initial settings sync in reloadFeatures() marked settings-originated values as vimrc overrides because registerVimOptions() had already activated its notify callback. Fixed by (1) moving clearSettingOverride() to after vim.setOption() in both setControlValue and all imperative onChange handlers, and (2) making registerVimOptions() return an activation function so the initial sync runs before notifications are enabled. (#125)

Options that require side effects (clipboard → setClipboardOption(), textwidth → setTextwidth(), guicursor → parseGuicursor()) use a SideEffectOpt type in the KNOWN_SET_OPTIONS table. This ensures all three code paths (vimrc set, Lua vim.opt, and initial settings load) invoke the same side-effect callback — eliminating the class of bug where an option works in vimrc but silently fails in Lua or vice versa.

The initial settings load during onload() is guarded by an initializing flag that suppresses reloadFeatures() and gutter reconfiguration side effects until onload() completes. Without this guard, the settings restoration loop triggers premature reloadFeatures() calls that create resources (VimModeTracker, GlobalKeyHandler) before onload() creates its own — leading to duplicate status bar elements and orphaned event listeners. The guard follows the same pattern as vimrcLoading/luaLoading. (#63)

set option scope

All plugin settings are now configurable via set options in .obsidian.vimrc. When vimrc is enabled (the default), vimrc values override the corresponding Settings UI values for the current session. Overrides are persisted in a configOverrides block in data.json so they survive Obsidian restarts — the base settings always reflect UI-set values, while configOverrides captures the last-known vimrc/Lua values. On startup, configOverrides are merged on top of base settings before CM6 extensions are created. See the full options table in README.md → “Supported set options”.

Additionally, whichkeygroup and whichkeylabel ex commands allow configuring which-key labels, and let g:mode_prompt_* allows customizing status bar mode text. These use merge semantics with the Settings UI (both sources contribute; vimrc wins on conflict).

Settings overridden by vimrc appear as disabled controls in the settings tab with a note showing the vimrc directive (e.g., “Set by vimrc: set scrolloff=10”). Changing a disabled setting requires editing the vimrc.

The following settings are intentionally not exposed via vimrc:

SettingReason
configModeCircular dependency — can’t control config file loading from vimrc or init.lua
leaderBindingsAlready achievable via nmap <leader>x :command in vimrc or vim.keymap.set in init.lua
pickerKeymapComplex array-valued keys — not suited for :set syntax

ignorecase, smartcase, hlsearch, incsearch, and gdefault are now configurable via :set / vim.opt (defaults match Neovim: ignorecase on, smartcase on, hlsearch on, incsearch on, gdefault off). wrap is controlled by Obsidian’s editor settings and is not exposed as a vim option.

signcolumn accepts auto, auto:N, yes, yes:N, no (N = 1–4, character slots). auto shows the sign column when marks exist and hides it when empty (causes layout shift, matching Neovim). yes always reserves gutter space. Clicking a mark label in the sign column moves the cursor to that line. Global marks (A–Z) render in a distinct color from local marks (a–z).

cursorlineopt follows Neovim: the cursor line’s number is highlighted only when cursorline is on and the value contains number (or is both), never while cursorline is off. The full grammar is accepted — line, screenline, number, both, and comma lists in any order (line,number, number,screenline) — with both as Neovim’s alias for line,number, and line+screenline rejected as Neovim rejects it. Input is normalized to one of five canonical spellings, which is what the settings dropdown stores. screenline highlights only the cursor’s display row of a wrapped line; it is drawn as a measured rectangle rather than a line decoration, because a CodeMirror line decoration spans the whole wrapped block and a mark decoration would stop at the last glyph instead of filling to the content edge.

The default is Neovim’s both. Existing vaults are pinned to the previous number default by a migration, so no installed configuration changes appearance; only new installs get both. The one case the migration cannot detect is an installation that has never written data.json — there is no stored signal distinguishing it from a first run, and a settingsVersion key could not have been written retroactively either. Such a vault has never had a setting changed, a vimrc/Lua override applied, or a global mark set.

linenumbermode is deprecated in favor of statuscolumn. linenumbermode=dual internally sets statuscolumn="%l %r". Changing linenumbermode no longer requires an Obsidian restart — reconfigureLineNumberGutter() forwards the mode to both line-number compartments, and that path reached no editor view until iterateEditorViews() was corrected.

statuscolumn provides a format string for customizing the gutter layout. Supported tokens: %l (line number respecting number/relativenumber, absolute fallback when both off), %r (relative number), %s (sign column marks, respects signcolumn auto/yes/no and width), %C (fold indicators, always active when present), %= (flex separator), literal text. When statuscolumn is set, all individual gutter columns are hidden — the unified gutter replaces them. When empty (default), individual settings manage gutters independently. Changing statuscolumn no longer requires an Obsidian restart: the unified gutter compartment is registered during plugin load, so reconfigureStatusColumnGutter() can swap it in place — that call simply never reached an editor view until iterateEditorViews() was corrected. Setting statuscolumn in your Lua config (vim.opt.statuscolumn = "%s %l %r %C") still applies it on startup. v1 limitations: no %{expr} Lua expressions, no %#HlGroup# highlight groups, no width specifiers (%-5l), no per-window statuscolumn, no v:virtnum for wrapped lines. Global only (vim.opt.statuscolumn). Invalid format strings silently fall back to empty (plugin-managed gutters).

Every Neovim option is recognized by name. Options not applicable to Obsidian (terminal, GUI, mouse, file I/O, etc.) are accepted silently. Options that exist but are not yet configurable log an info-level note. Only truly unknown options (typos, non-Neovim options) produce a console.warn. Each option is logged at most once per vimrc load/reload to avoid console noise.

nmap L $ may not work via vimrc

nmap L $ (mapping L to end-of-line) may not work when loaded from .obsidian.vimrc in some environments. Investigation (spike17, Diag 6) found that the mapping mechanism itself works correctly — Vim.map('L', '$', 'normal') at runtime successfully maps L to $ and handleKey('L') moves to end-of-line. The issue is a vimrc file I/O timing problem: the loadVimrc function sometimes reads an empty or missing file during the active-leaf-change lifecycle, resulting in vimrcCommandCount: 0 and an empty deferred maps array.

Diagnostic findings (spike17 Diag 6):

  • Vim.map('L', '$', 'normal') works at runtime — handleKey('L') moves to ch:15 (end of line)
  • handleEx('nmap L $') works at runtime — identical result
  • getKeymap('normal') shows the L → $ entry after runtime application
  • After vimrc load, vimrcMaps is empty and vimrcCommandCount is 0 — the file was not read successfully
  • The mapping mechanism (ExCommandDispatcher.map, _mapCommand, doKeyToKey) is correct — the issue is in file I/O timing during the active-leaf-change handler

Mitigation (multi-layered):

  1. stat() readiness probe: readVimrcFile now calls app.vault.adapter.stat(path) before read() to verify the file exists in the vault index. This avoids attempting reads on non-existent files and provides stat.size to distinguish genuinely empty files from timing-empty reads.
  2. Two-phase parsing: File reading/parsing is decoupled from command application. readAndParseVimrcFile parses the vimrc without needing a CM adapter; applyVimrcCommands applies all commands, deferring cm-dependent ones to pendingExCommands.
  3. Smart retry with backoff: When stat.size > 0 but read() returns empty (timing issue), retries with exponential backoff (50ms, 100ms, 200ms, 400ms — 750ms total). Genuinely empty files (stat.size === 0) skip retries entirely.
  4. User-facing Notice on exhaustion: If all retries fail on a non-empty file, a Notice is shown: “Vim Motions: vimrc found but could not be read — try reloading the plugin.” This surfaces the issue for user reports.
  5. vimrcLoading try/finally: The vimrcLoading flag is now reset in a finally block, so a failed loadVimrc() call no longer permanently blocks future retry attempts on subsequent active-leaf-change events.
  6. Lua loader parity: The same stat()+retry pattern is applied to readLuaFile() in src/lua/loader.ts, which previously had no retry logic at all.

Workaround: if vimrc mappings are not applied despite the improved retry mechanism, reload the plugin via Settings → Community plugins (disable then enable). At runtime, mappings can be applied via Obsidian’s developer console: CodeMirrorAdapter.Vim.map('L', '$', 'normal').

set textwidth via vimrc may not affect gq

set textwidth=20 in .obsidian.vimrc may not change the wrap width used by the gq/gw operators if the vimrc file is not loaded successfully (same file I/O timing issue as nmap L $ — see improved retry mechanism above). The textwidthSetExplicitly guard in options.ts correctly prevents CM Vim’s defineOption callback from resetting the value when the vimrc does load successfully.

With the vimrc-settings parity changes, set textwidth=N in vimrc also updates this.settings.textwidth via the onSettingOverride callback. The textwidth setting is now available in the Settings UI (Settings → Vim Motions → Vim engine → Text width). The getTextwidth() function used by gq/gw still reads from the module-level variable, so the vimrc I/O timing issue can still cause the value to not propagate.

Workaround: if set textwidth=N is not taking effect, reload the plugin. At runtime: CodeMirrorAdapter.Vim.setOption('textwidth', 20).

noremap cannot swap built-in single-key motions

nnoremap j k / nnoremap k j does not swap the j and k motions. This is a codemirror-vim architectural constraint: when a noremap mapping’s rhs is dispatched, the key handler skips all user-defined keymap entries and only searches the default keymap. Since user-defined entries are inserted at the front of the keymap array via unshift, the noremap dispatch (which starts at keyMap.length - defaultKeymapLength) correctly finds the original motion. However, the lhs side of the swap still resolves to the original motion as well, because codemirror-vim’s noremap flag is tracked globally during dispatch — meaning both sides of a swap end up resolving to the default keymap.

This limitation is confirmed upstream in obsidian-vimrc-support issue #16, where the maintainer noted: “CodeMirror doesn’t support noremap […] recursive mappings are not possible in CodeMirror anyway so map or nmap should work.”

noremap does work for preventing recursion in multi-key mappings (e.g. noremap G G$) and for remapping keys to different key sequences. It only fails when trying to swap two built-in single-key motions with each other.

Only an exact Escape mapping overrides the built-in mode exit

<Esc> is remappable in insert and visual mode, and a mapping you set wins over the built-in mode exit. Only a full match does, however: with inoremap <Esc>q ZZ and nothing bound to bare <Esc>, pressing <Esc> exits insert mode instead of waiting to see whether a q follows.

This matches what Neovim does in the same situation (measured on 0.12.5: inoremap <Esc>q ZZ then a <Esc> leaves insert mode with the buffer unchanged), and here it is also a safety property rather than only a parity one. The bundled engine’s insert-mode partial-match branch reports the key as consumed without arming the insertModeEscKeysTimeout fallback for a non-character key, so honouring the partial would leave no way out of insert mode at all — <Esc> would be swallowed indefinitely while the engine waited for a second key that the user may never intend to press.

Two related behaviours are deliberate and match Vim:

  • <C-[> follows your <Esc> mapping, because <C-[> is Escape — both send 0x1b. <C-c> does not, because it is a distinct key. That makes <C-c> a dependable way out of insert mode regardless of what <Esc> is bound to.
  • A recursive imap <Esc> <Esc> falls back to the built-in exit rather than resolving to a no-op, because a mapping already being expanded is skipped when resolving the override.

Table navigation on non-US keyboards

]| and [| use the pipe character (|), which on many non-US keyboard layouts (German, Dutch, Nordic, etc.) requires AltGr or a modifier combination. codemirror-vim’s vimKeyFromEvent translates AltGr keypresses as <C-A-|> or <A-|>, which does not match the registered ]| keybinding.

The alternative keybindings ]c and [c are provided for this reason and work on all keyboard layouts.

Which-key overlay

The which-key overlay has three modes (configurable via Settings → Vim Motions → Which-key hints):

  • Off — no which-key overlay
  • Leader key only — shows leader bindings after pressing the leader key (after the configurable popup delay, default 500ms)
  • All partial keys — shows available continuations after any partial key sequence (operators, prefix keys, leader)

The popup delay is configurable via Settings → Vim Motions → Which-key popup delay or set whichkeydelay=<ms> in vimrc (range 0–2000ms, default 500ms). Once the popup is visible, subsequent keystrokes update it instantly — the delay only applies to the initial appearance.

Global which-key popup disappears quickly in non-editor views: Fixed. The global key handler’s sequence timeout now restarts when partial completions exist instead of resetting unconditionally. This matches editor which-key behavior where the popup stays until the command completes. (#97)

In “all” mode, the overlay reads the fork’s getInputState() to detect operator-pending state and vim.status for partial key chords. Operator-pending mode shows grouped next-key options filtered to motions, text objects, and operatorPending actions. Prefix keys (like g, z) show getCompletions() results. Special keys (<Left>, <C-n>, etc.) and insert-only entries are filtered out. When the key buffer is in leader scope (starts with the leader key and not in operator-pending mode), showCompletions() uses leaderBindings instead of getCompletions() to show only user-visible leader keymaps — matching the behavior of the “leader key only” mode. (#91)

The overlay attaches to the active editor pane’s contentEl with position: absolute, so it stays within the editor bounds and doesn’t cover other panes. Maximum height is 40% of the pane. The multi-column grid layout uses auto-fill with minmax(200px, 1fr) columns.

The overlay adds padding-bottom equal to Obsidian’s status bar height so that keybinding rows are not hidden behind the status bar. In horizontal split views, the padding is only applied when the editor pane’s bottom edge is adjacent to the status bar — top panes (whose bottom edge doesn’t reach the status bar) show no extra padding. The global which-key (workspace navigation) always applies the padding since it spans the full workspace.

Sort order

Configurable via Settings → Vim Motions → Which-key sort order, vim.opt.whichkeysort in Lua, or set whichkeysort=<order> (alias wks) in vimrc. Two modes:

  • which-key (default) — matches which-key.nvim defaults: individual keys first, groups last, alphanumeric keys before special keys (<…>), natural alphabetical tiebreaker, lowercase before uppercase.
  • Groups first — groups appear before individual keys, both categories sorted alphabetically.

Icons

Configurable via Settings → Vim Motions → Which-key icons, vim.opt.whichkeyicons in Lua, or set whichkeyicons in vimrc (default: on).

When enabled, Lucide icons appear next to entries in the which-key popup, rendered via Obsidian’s setIcon() API. The column layout matches which-key.nvim: key → separator (➤) → icon → description. Icons use stroke="currentColor" and are colored via the CSS color property on the icon span.

Icons and colors can be assigned per group label and per command label via Settings UI, Lua API (vim.obsidian.whichkey.set_group/set_label/add with icon and color opts), or vimrc (whichkeygroup <leader>t Table icon=table color=blue). 8 named Obsidian colors (red, orange, yellow, green, cyan, blue, purple, pink) map to var(--color-<name>) CSS variables. Arbitrary CSS color strings are also accepted (sanitized against injection). Default icon color is --text-muted.

Built-in groups register default icons: Table (table, blue), EasyMotion (zap, yellow), Harpoon (anchor, orange). User-configured icons override defaults via the standard priority merge (Lua > vimrc > Settings).

When icons are enabled, rows without an assigned icon receive an empty spacer span to maintain column alignment across all rows. When icons are disabled globally, no icon spans or spacers are rendered.

Grouping

When Which-key leader grouping is set to “Grouped” (default), bindings sharing a common prefix key are collapsed into a single group entry (e.g. t → Table (+11)). Pressing the group key drills down to show only the bindings within that group. Groups are sorted before ungrouped entries. Setting the mode to “Flat” restores the original behavior of listing all bindings individually.

Grouping applies to all completions — not just leader-scoped bindings. Any multi-key prefix (g, z, [, ], user-defined sequences) benefits from grouping when multiple completions share a next key.

Group labels

Groups are labeled with a generic +N keys text by default. Custom labels can be configured via Settings → Vim Motions → Which-key group labels using the full key prefix:

  • Leader-relative groups: use the leader character + prefix (e.g. \t for table commands under leader \)
  • Non-leader groups: use the raw prefix (e.g. gr for the replace-with-register operator, cs for surround changes)
  • <leader> token: expanded to the actual leader key (e.g. <leader>t resolves to \t with default leader)

Built-in features register default labels (Table, EasyMotion) that user entries can override. Whitespace in the prefix field is trimmed.

EasyMotion commands shown incorrectly with space leader (Fixed)

Status: Fixed. LeaderRegistry.addBinding() and addGroupLabel() now normalize keys via normalizeVimKey() at storage time, ensuring consistent <Space> notation across all comparison paths. (#94)

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) The stored binding keys used raw space (" f") while the drill-down prefix used normalized notation ("<Space>"), so the startsWith filter never matched. The group label had the same mismatch. Both addBinding() and addGroupLabel() now normalize their inputs. (2) In grouped mode, buildNextKeyEntries() treated <Space> as a “special key” (like <CR>, <Left>) and silently dropped all entries whose first key was <Space> from the grouping display. Fixed by exempting <Space> from the special key check — it is a typeable key that users press.

Descriptions not showing for Lua keymaps with space leader (Fixed)

Status: Fixed. Key normalization unified between the codemirror-vim fork and the which-key overlay. (#58)

vim.keymap.set("n", "<leader>ff", function() ... end, { desc = "Find" }) with vim.g.mapleader = " " showed lua-action-0 instead of "Find" in the which-key popup. String-action keymaps showed the raw command (e.g., :Oil<CR>), and built-in feature descriptions (EasyMotion, Harpoon) reverted to internal function names (e.g., harpoonSelect1).

Root cause: the codemirror-vim fork normalizes literal space characters to <Space> notation when storing keymaps (_mapCommand → normalizeKeyString), and getCompletions()/getKeymap() return keys in this normalized form. The fork’s key event handler (vimKeyFromEvent) also emits <Space> for space bar presses. However, the which-key overlay stored label keys with literal spaces (from replaceLeaderKey) and compared the raw leader key character against <Space> event keys — all lookups missed. The leader-only which-key mode additionally never triggered with space as leader because "<Space>" !== " ".

Fix: added normalizeVimKey() mirroring the fork’s normalizeKeyString, applied at label storage time in rebuildWhichKey() and at lookup time in showLeaderBindings()/showCompletions(). Added normalizedLeaderKey for key event comparison in onKeyPressLeaderOnly().

Leader overlay triggered by a literal-argument key (Fixed)

Status: Fixed. onKeyPress() carries the previous key’s expectLiteralNext state forward and skips leader handling when the key was consumed as a literal argument. (#186)

With space as the leader, r<Space> replaced the character under the cursor and then opened the leader overlay as if <Space> had been pressed on its own. The overlay was not a real leader press — a following <leader>w did not complete an EasyMotion sequence — so the hint contradicted the actual key state. The same applied to every command that waits for a literal <character>: f, t, m, q, ". Replace mode (R) was unaffected, because the overlay already dismisses in insert mode.

Root cause: the fork buffers r as a partial match, sets expectLiteralNext, and signals vim-keypress only after the argument key has been consumed and the input state cleared. At the time the overlay sees the argument key, expectLiteralNext and the key buffer are already reset, so it is indistinguishable from a standalone leader press. Checking vim state at event time cannot work; the state must be remembered from the previous key. The key buffer is also checked at consumption time, so a pending r cleared by a blur does not swallow a later genuine leader press.

Automatic obcommand description resolution

Mappings to :obcommand <id><CR> or :ob <id><CR> without an explicit desc now auto-resolve to Obsidian’s native command name in the which-key popup. For example, vim.keymap.set("n", "<leader>r", ":ob app:go-back<CR>") displays “Navigate back” instead of the raw :ob app:go-back<CR> string. This works for both editor which-key (leader bindings) and global which-key (:gmap bindings). Explicit desc options always take priority. Unknown command IDs (e.g., from uninstalled plugins) fall back to the raw string. Descriptions are automatically localized to match the user’s Obsidian language setting. (#62)

Limitations

  • Function callbacks with vim.cmd("ob ..."): When vim.keymap.set uses a function callback that calls vim.cmd("ob ...") or vim.cmd("obcommand ..."), the which-key popup cannot auto-resolve the Obsidian command name. Lua functions are opaque — the plugin cannot introspect the function body to extract the command ID. Use a string RHS (:ob <id><CR>) for auto-resolution, or provide an explicit desc option. See examples under “Mapping examples” in the Lua configuration docs.
  • User-defined mappings via Vim.map() appear in completions but without friendly descriptions when the rhs is not an :obcommand/:ob pattern (shown as the raw rhs key sequence)
  • The overlay does not show during macro playback or when a register prefix ("a) is pending
  • Icon IDs are validated at render time — invalid icon names (not in Obsidian’s Lucide bundle) result in an empty spacer; no error is thrown
  • Icons in pop-out windows depend on setIcon() working with foreign Document objects — if Obsidian’s API references document internally, icons may not render in pop-out windows

<C-w> prefix conflict with Obsidian hotkeys

Obsidian’s default “Close current tab” hotkey is bound to Ctrl+W. Users must unbind it in Settings → Hotkeys (search for “Close current tab”) for the <C-w> prefix (<C-w>h/j/k/l, <C-w>v, <C-w>s, <C-w>c, <C-w>q, <C-w>o) to work. This is also noted in the settings toggle and README. The close-tab functionality remains available via :q, :quit, <C-w>c, or <C-w>q (the latter two work once the Obsidian hotkey is removed).

Conflict detection: The plugin now detects active hotkey conflicts on load (desktop only, when workspace nav is enabled). A one-time Notice per plugin version alerts users to conflicts. A “Check hotkey conflicts” button in Settings → Vim Motions → Navigation lists each active conflict with step-by-step unbinding instructions. Detection reads hotkeys.json — if a command ID is absent (default binding active) or has a non-empty array (custom binding), it’s a conflict. An empty array [] means the user explicitly unbound it (no conflict).

Global workspace navigation

Status: Working. Workspace commands work across all Obsidian views (PDF, graph, canvas, image, backlinks, etc.), not just markdown editors. (#35)

A capture-phase keydown listener on document intercepts workspace-relevant keystrokes when no CodeMirror editor or text input is focused. When an editor IS focused, events propagate to codemirror-vim unchanged.

Supported keys in non-editor views

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 (tabs), Ngt (Nth tab), H/L (prev/next tab), Ctrl-o/Ctrl-i (history)

Hint actions: f (activate/click), F (open in new pane), yf (yank URL/text), df (close tab/pane) — see Hint mode actions

Scrolling: j/k (line), Nj/Nk (N lines), gg/G (top/bottom), Ctrl-u (half page up), Ctrl-d/f/b (see below)

Ex commands: : opens a standalone command modal with tab-completion for globally-safe ex commands (:q, :wq, :e, :sp, etc.)

Ctrl-d, Ctrl-f, Ctrl-b require unbinding Obsidian defaults

Obsidian’s default hotkeys for Ctrl-d (delete paragraph), Ctrl-f (search), and Ctrl-b (toggle bold/sidebar) intercept these keys at the Electron level before any DOM event listener fires — including capture-phase listeners. The plugin’s handler never receives the keydown event.

Fix: Unbind the conflicting hotkeys in Settings → Hotkeys (search for the key combination and remove the binding). After unbinding, Ctrl-d/f/b work as expected for half-page/full-page scrolling.

Ctrl-u works without any changes because Obsidian has no default hotkey for it.

This is the same class of issue as the <C-w> prefix conflict (documented above) — Obsidian’s hotkey system takes priority over plugin DOM event listeners.

Scroll target detection

The global handler finds the scrollable element in the active view by walking the DOM tree for the largest element with overflow-y: auto|scroll and scrollHeight > clientHeight. This works for standard scrollable views (PDFs, reading mode, backlinks, file explorer).

Unsupported scroll targets: Canvas and graph views use non-standard rendering (infinite canvas, WebGL) without a traditional scrollable container. j/k and scroll commands are silently no-ops in these views.

H/L behavior in non-editor views

In standard Vim, H/L move the cursor to the top/bottom of the visible screen. In non-editor views there is no cursor, so H/L are repurposed for previous/next tab switching, matching obsidian-vim-keynav conventions. Editor behavior is unchanged.

Ctrl-o/Ctrl-i dual purpose

In editor context, codemirror-vim uses <C-o>/<C-i> for the within-file jumplist. In non-editor views, the global handler maps them to app:go-back/app:go-forward (Obsidian’s history navigation). There is no conflict because the global handler only fires when no editor is focused.

gt always goes to first tab / Ngt count ignored (Fixed)

Status: Fixed. gt now goes to next tab (no count) or Nth tab (with count) in both editor and non-editor views. (#97)

Three bugs fixed: (1) In non-editor views, the global key handler’s dispatch() used this.count || 1, making count 0 (no count typed) indistinguishable from count 1 — gt always called gotoNthTab(1). (2) In editor views, gt was mapped to workspace:next-tab which ignores actionArgs.repeat entirely — 2gt always went to next tab. Fixed by using actionArgs.repeatIsExplicit to distinguish “no count” from “explicit count”. (3) gotoNthTab counted all workspace leaves including sidebar panes — 3gt could navigate to a sidebar pane. Fixed by filtering with leaf.getRoot() === rootSplit.

Editor-only ex commands

The standalone ex command modal (: in non-editor views) supports 34 commands that don’t require a CmAdapter. The following editor-dependent commands show “Not a global command” when invoked from the modal: :e!, :saveas, :read, :marks, :delmarks, :changes.

Workspace navigation in plugin views

Status: Fixed. Two-level interception implemented. (#47)

The global key handler uses a three-gate interception system (always active on desktop, independent of the workspace navigation setting):

  • Structural keys (<C-w>*, gt/gT, <C-o>/<C-i>, :) — always intercepted in non-editor views, regardless of view type. : is always registered; pane/tab navigation keys (<C-w>*, gt/gT, etc.) are only registered when workspace navigation is enabled.
  • Content keys (j/k scroll, count-prefix digits, H/L, scroll commands) — only registered when workspace navigation is enabled, and only intercepted in whitelisted view types (markdown, graph, pdf, canvas, empty, image, bases). In plugin views (Spaced Repetition, Excalidraw, etc.), these keys pass through to the plugin.
  • Hint keys (f, F, yf, df) — intercepted unless an editor or input is focused.

Trade-off: In plugin views, pressing g followed by a standard-gated key (e.g., gg for scroll-to-top) will consume the keystrokes without effect, because the g prefix enters the handler due to structural completions (gt/gT). Use <C-w> sequences for workspace navigation in plugin views.

Customization: The view type whitelist can be overridden via Settings → Vim Motions → Workspace navigation view types or set workspacenavviewtypes=markdown,graph,pdf,canvas,empty,image,bases in vimrc.

Hint mode actions

Status: Working. Hint mode supports multiple vimium-style actions with a context-appropriate split between editor and non-editor views.

Non-editor context (GlobalKeyHandler)

When a non-editor view (graph, PDF, canvas, etc.) is focused, full vimium-style hint bindings are available:

KeyActionBehavior
fActivateClick button, focus pane, navigate link, focus input
FOpen newOpen target in new tab (Ctrl+Meta click for generic targets, openLinkText for links, duplicateLeaf for panes)
yfYankCopy URL for links, note path for tabs, display text for others
dfCloseClose tab/pane via leaf.detach(); Notice for non-closeable targets
gfContext menuOpen right-click context menu on target via contextmenu MouseEvent with element-center coordinates

Count prefix works: 3f activates three targets sequentially (overlay re-shown between each). 3yf yanks three URLs. 3df closes three tabs.

The y and d keys enter pending states (Y_PENDING/D_PENDING) that only accept f as continuation. Any other key resets the sequence. Chord display shows y or d while pending, using the existing SEQUENCE_TIMEOUT of 1000ms.

Editor context (vim engine)

<leader><leader>h triggers hint mode (unchanged). Action is selected by modifier keys during label selection:

  • No modifier → activate (click/focus/navigate)
  • Ctrl/Cmd held while typing label → open in new pane
  • Shift held while typing label → open context menu

Shift key normalization: waitForHintKey() lowercases e.key when Shift is held so that Shift+a matches the lowercase label a instead of dismissing the overlay. (#104)

Yank, close, and context menu are not mapped to editor key sequences (they conflict with vim’s native operators). They are registered as Obsidian commands for custom hotkey assignment:

  • vim-motions:hint-open-new-pane — “Hint: open in new pane”
  • vim-motions:hint-yank — “Hint: yank link or text”
  • vim-motions:hint-close — “Hint: close tab or pane”
  • vim-motions:hint-context-menu — “Hint: open context menu”

Target classification

Each hint target is classified by type during discovery, before label assignment. The classification determines per-action behavior:

  • .workspace-leaf-content → pane (focus via setActiveLeaf; F action duplicates the leaf into a new tab via duplicateLeaf)
  • .workspace-tab-header → tab (close via leaf.detach())
  • a[href], [data-href], .cm-underline, .cm-hmd-internal-link, .cm-link, .cm-url → link (internal links navigate via navigateWithJump; external URLs open via window.open()). .cm-underline spans in Live Preview had no href or data-href attributes, causing wikilinks and markdown links to fall through to the generic click handler (no-op on CM6 spans). Fixed in three phases: (1) resolveCmUnderlineHref() uses EditorView.posAtDOM() to convert the DOM element to a document offset, then calls findLinkAtCursor() to extract the link target from the raw markdown text. (2) Hint labels only appeared on .cm-underline spans, which are only present in Live Preview when the cursor is NOT on the link’s line. When the cursor is on the line, wikilinks render as .cm-hmd-internal-link spans and markdown links render as .cm-link/.cm-url spans — neither was in TARGET_SELECTOR. In Source mode, wikilinks always render as .cm-hmd-internal-link. Fixed: added .cm-hmd-internal-link, .cm-link, and .cm-url to TARGET_SELECTOR with deduplication filters to prevent multiple hints per link (aliased wikilink sub-spans, nested .cm-underline inside .cm-hmd-internal-link, markdown link URL spans when text span exists). (3) getEditorViewFromElement() falls back to the MarkdownView.editor.cm path when the DOM .cmView.view property is unavailable (which is the case in Obsidian’s runtime). External URLs resolved by resolveCmUnderlineHref() are opened via window.open() instead of falling through to the generic click handler. (#85)
  • input, textarea, select, [contenteditable] → input (focus; <select> cycles to next option)
  • button, .clickable-icon, [role="button"] → button (click)
  • .workspace-drawer-vault-switcher → button (click — opens vault switcher menu). Added in response to #104: the vault switcher is a plain <div> without button semantics, so it was not matched by any standard selector
  • everything else → generic (pointer event sequence + click). All synthetic events include clientX/clientY from the element’s bounding rect center via getElementCenter(), ensuring dropdown menus and popovers position correctly near the clicked element instead of at (0, 0). (#104)

Target discovery filters:

  • Elements with .is-measuring class are excluded (Obsidian 1.13+ shadow <select> copies used for layout measurement)
  • Child elements inside .checkbox-container are excluded (the container itself is the clickable toggle, not its inner <input>)
  • input[type="hidden"] and disabled elements are excluded
  • .cm-underline inside .cm-hmd-internal-link is excluded (parent is the preferred target)
  • .cm-formatting-link spans are excluded (bracket characters [, ], (, ) should not be hint targets)
  • Only the first .cm-hmd-internal-link sibling per link group is kept (aliased wikilinks [[Target|Alias]] produce 3 sub-spans)
  • Only the first .cm-link sibling per link group is kept (formatting brackets produce separate .cm-link spans)
  • .cm-url with .cm-string class is excluded (URL inside markdown link parentheses — the .cm-link text span is the hint target). Bare URLs (.cm-url without .cm-string) are kept

Settings gating

Hint actions in non-editor context require BOTH enableWorkspaceNav (gates GlobalKeyHandler) AND enableHintMode (gates hint actions). Disabling hint mode via settings stops f/F/y/d interception in GlobalKeyHandler. The existing enableHintMode setting controls all hint labels — in both editor and non-editor contexts.

Navigation keys (j/k/g/z/:/H/L/Ctrl-combinations) are suppressed when any Obsidian modal is open (settings, command palette, etc.) via isModalOpen(). This prevents scrolling and navigation from interfering with modal interaction.

Hint actions (f/F/yf/df) are NOT suppressed in modals — they use a separate shouldInterceptHints() gate. This allows hint labels to target and activate modal controls (buttons, toggles, dropdowns, text fields). After activating a toggle or dropdown in a modal, the element is blurred so f can immediately re-trigger hint mode without pressing Escape.

During hint label selection, GlobalKeyHandler bails entirely via an isHintModeActive() flag, preventing label characters from being intercepted as navigation or hint-trigger keys.

Clipboard fallback

hintYank uses navigator.clipboard.writeText() with a fallback to a temporary textarea + document.execCommand('copy') for environments where the Clipboard API is restricted. The deprecated execCommand path is defensive — in Obsidian’s Electron runtime, navigator.clipboard should always work.

Modifier keys dismiss hint overlay (Fixed)

Status: Fixed. Pressing Ctrl, Shift, Alt, or Meta alone during hint mode no longer clears labels. The waitForHintKey() handler filters modifier-only keydown events with an early return and calls e.preventDefault() + e.stopPropagation() to prevent the event from propagating to Obsidian’s hotkey system. Without stopPropagation, the modifier keydown could leak through to bubble-phase listeners in Obsidian and cause side effects depending on the user’s configuration. Modifier keys combined with label characters still work as before (e.g., Ctrl+label upgrades activate to open-new). (#98)

Count prefix (2F) shifts focus to new tab (Fixed)

Status: Fixed. When using a count prefix (e.g., 2F), focus now stays on the original leaf between activations. The createHintAction run() function saves the active leaf before waitForHintKey when count > 1 and restores it via setActiveLeaf after each activation, before scheduling the next round. hintActivate is now async and awaits navigateWithJump() and duplicateLeaf() — previously these were fire-and-forgotten via void, causing a race condition where openLinkText() could resolve after setActiveLeaf(originalLeaf) and steal focus back to the new tab. The race manifested on slower machines or with heavier vaults. The hintMode vim action (<leader><leader>h) now passes actionArgs.repeat to activate(), enabling count prefix in editor context as well. (#98)

Stale target handling

Targets are validated via el.isConnected before action execution. If an element has been removed from the DOM between overlay display and label selection (e.g., Obsidian re-rendered a view), a Notice is shown and the action is aborted. During count iterations, if re-activation finds no visible targets, it stops silently without repeated Notices.

Cross-document jump history (Ctrl-o / Ctrl-i)

codemirror-vim’s built-in <C-o> and <C-i> handle the within-file jump list (jumping between cursor positions in the current document). Overriding them for cross-document navigation would break within-file jumps.

Cross-document navigation is available via :back and :forward ex commands, which map to Obsidian’s built-in back/forward history. Users who prefer keybindings can add mappings in their vimrc:

nmap <C-p> :back
nmap <C-n> :forward

gf opens file switcher, not file path under cursor

Standard Vim’s gf opens the file whose path is under the cursor. In Obsidian, bare file paths in notes are uncommon — most navigation uses [[wikilinks]] (handled by gd). Our gf opens Obsidian’s quick switcher instead, which lets users search any file by name. This is more practical for a note-taking context.

Mobile support

The plugin is disabled by default on mobile (enableOnMobile: false). Most mobile users sync the plugin to their vault without a hardware keyboard attached, and the Vim engine puts the editor into Normal mode with no obvious way to return to typing (soft keyboards lack Escape and :). (#52)

To enable: toggle Settings → Vim Motions → Mobile → Enable on mobile, or use the command palette: Vim Motions: Toggle enable on mobile. Both are accessible even when the plugin is disabled on mobile. A reload is required after changing the setting.

When disabled on mobile, the plugin’s onload() returns early after registering only the settings tab and the toggle command. No editor extensions, event listeners, Vim engine initialization, or status bar elements are registered.

When enabled on mobile, EasyMotion and hint mode remain disabled because they depend on desktop-only Obsidian globals (activeDocument, activeWindow). All other features work, though on-screen keyboard users are further limited by Obsidian’s soft keyboard, which does not support : and / command entry.

Features by platform:

FeatureDesktopMobile (enabled) + physical keyboardMobile (enabled) + soft keyboardMobile (disabled)
Core Vim motions✅✅⚠️ Limited❌ Off
Text objects✅✅⚠️ Limited❌ Off
EasyMotion✅❌ Disabled❌ Disabled❌ Off
Hint mode✅❌ Disabled❌ Disabled❌ Off
Ex commands (:w, :q)✅✅❌ No : entry❌ Off
Search (/, ?)✅✅❌ No / entry❌ Off
Workspace nav (<C-w>)✅✅❌ No modifier keys❌ Off
Global workspace nav✅❌ Disabled❌ Disabled❌ Off
Status bar✅✅✅❌ Off
Vimrc✅✅✅❌ Off
Settings✅✅✅✅
Toggle command✅✅✅✅
Popout windows✅N/AN/AN/A

Neovim Ex commands not applicable in Obsidian

The following Neovim Ex commands have no meaningful equivalent in Obsidian and will not be implemented. Users expecting these commands will see “Not an editor command” from CM Vim’s Ex parser.

Shell / system integration

CommandNeovim descriptionWhy N/A
:!{cmd}Execute shell commandObsidian has no shell access (sandboxed Electron app)
:read !{cmd}Insert shell outputNo shell access
:terminalOpen terminalNo terminal emulator in Obsidian
:cd / :lcd / :pwdChange/show working directoryObsidian vault is the working directory; no directory navigation
:makeRun buildNo build system concept

Quickfix / location list

CommandNeovim descriptionWhy N/A
:cnext / :cprev / :copen / :clistQuickfix navigationNo quickfix or error list (Obsidian is not an IDE)
:lnext / :lprev / :lopenLocation listSame — no location list concept

Tags / ctags

CommandNeovim descriptionWhy N/A
:tag / :tjump / :tselectTag navigationObsidian has no ctags integration. gd provides link-based “go to definition” instead.

Scripting / autocommands

CommandNeovim descriptionWhy N/A
:autocmd / :augroupAutocommandsObsidian plugins handle events via the Plugin API, not Vim autocommands
:function / :call / :if / :forVimscriptThe plugin is not a Vimscript interpreter. Use .obsidian.vimrc for mappings.

Diff mode

CommandNeovim descriptionWhy N/A
:diffthis / :diffsplit / :diffget / :diffputDiff operationsNo diff view in Obsidian’s editor

Other

CommandNeovim descriptionWhy N/A
:args / :argdo / :next / :prevArgument listNo arglist concept — Obsidian manages open files via tabs
:resizeResize windowObsidian manages pane sizing automatically
:tabmoveReorder tabsObsidian does not expose a tab reorder API
:viewOpen file read-onlyObsidian has no read-only mode for notes
:bunloadUnload buffer from memoryObsidian manages editor memory internally
:menuCreate GUI menusNo Vim-style menu system
:spell*Spelling commandsObsidian has its own built-in spell checker

Behavioral deviations

These commands exist but behave differently from Neovim:

CommandNeovim behaviorObsidian behaviorReason
YMapped to y$ by defaultMapped to y$ by plugin (overrides CM Vim’s yy default)Follows Neovim convention per design principle #2
QReplay last recorded macroMapped to @@ by plugin (overrides CM Vim’s unmapped Q)Follows Neovim convention
:wall / :waSave all modified buffersSaves only the current fileObsidian auto-saves; a true “save all” would need to iterate all leaves
gfOpen file path under cursorOpens Obsidian quick switcherWikilinks (gd) are more natural for note navigation
zO / zC / zARecursive fold open/close/toggleMaps to the same action as zo/zc/zaFixed. zO/zC/zA/zD now operate recursively using range containment on CM6’s foldable regions.
zn / zNFold none (disable folding) / fold normal (re-enable)Not implementedFixed. zn/zN/zi implemented via foldEnableField StateField. Fold gutter arrows remain visible (shows foldable regions) but fold operations are suppressed.
it / atHTML tag text objects (CM Vim native via XML mode)Plugin-implemented via raw text scanningCM Vim’s expandToTag requires findMatchingTag/findEnclosingTag functions from a parser mode not active in Markdown

Ex :m/:t address parsing (Fixed)

Status: Fixed. The :m/:move and :t/:copy/:co ex commands now fully support absolute addresses (0, $, ., line numbers), relative addresses (+N, -N), range syntax (1,2m$), and mark addresses ('a). The fork’s parseLineSpec_ handles the source range parsing; the plugin’s parseLineTarget handles the destination address. Newline handling at document boundaries (inserting at position 0 or after the last line, moving the last line) is now correct.

Test coverage: test/specs/vim-builtin/ex-move-copy-normal.e2e.ts — strengthened from crash-guard-only tests to behavioral assertions: :m0, :m$, :m-2, :m3, :1,2m$, :t0, :t$, :1,2t$.

Select mode and Virtual Replace mode

  • Select mode: selectmode=mouse does not work — permanent platform limitation. CM6 does not expose the low-level mouse event API needed to intercept mouse-initiated selections and convert them to select mode. :smap/:sunmap fallback to :vmap when no select-specific mapping exists (matches Neovim). selectmode=key and keymodel=startsel options are accepted but shifted cursor key behavior is not functional.
  • Virtual Replace: TAB virtual-column handling is basic — East Asian Width (double-width CJK characters) is not yet accounted for in column width calculation. Newline handling in vreplace mode is simplified; gR does not delete the rest of the line (falls through to CM6 default).
  • Mode indicators for select, v-replace, command, search, and insert-normal require fork mode (built-in vim mode OFF).
  • Operator-pending mode indicator is not shown (too transient to be useful in the status bar).

| dG | Deletes from cursor to end of file, no trailing newline | Fixed in fork | The fork’s operators.delete now expands the anchor to include the preceding newline when deleting linewise to end of file. |
| >> | Cursor at first non-blank after indent | Fixed in fork | The fork’s operators.indent now returns cursor at column 0, matching Neovim behavior. |
| V + > | Cursor at first non-blank after visual indent | Fixed in fork | Same fix as >> — cursor at column 0 after indent. |
| d0 | No-op at column 0 (zero-width motion) | Fixed in fork | Zero-width exclusive range produces no-op as expected. |
| << | Unindent by shiftwidth spaces | Fixed in fork | Fork’s indent operator now reads getOption('shiftwidth') and getOption('expandtab'), falling back to CM6’s tabSize/indentWithTabs when the vim options are not defined. |
| dd | Cursor stays at same column | Fixed in fork | Fork preserves cursor column after linewise delete instead of moving to first non-blank. |
| J | Strips trailing whitespace before join | Fixed in fork | Fork strips trailing whitespace from current line before adding join space, preventing double spaces. |
| di{ multiline | Preserves bracket lines (a{\n}b) | Fixed in fork | Fork deletes inner content lines only, keeping opening/closing bracket on their own lines. |
| dj/dk boundary | No-op at document start/end | Fixed in fork | Fork returns null from moveByLines when j/k can’t move to a different line. |
| :s cursor | First non-blank of last affected line | Fixed in fork | Fork’s doReplace positions cursor at first non-blank instead of column 0. |
| % + strings | Skips brackets in string/comment tokens | Fixed in fork (string-aware scanForBracket) | Fork’s moveToMatchedSymbol aborts when the first bracket is in a string, and scanForBracket now skips brackets in string/comment tokens during matching. In Markdown, Lezer does not classify double-quoted text as string tokens, so the (a")"b) test case remains a deviation in Markdown context only. |
| db cross-line | Includes leading whitespace when crossing lines | Fixed in fork | Fork expands delete range to include whitespace-only prefix before cursor when delete crosses a line boundary. |
| da" whitespace | Deletes quotes and adjacent whitespace | Fixed in fork | Fork’s findBeginningAndEnd now consumes trailing whitespace (or leading if no trailing) after inclusive quote expansion, matching Neovim’s a" behavior. |
| :join cursor | Cursor at column 0 of joined line | Fixed in fork | Fork’s ex command handler sets cursor to (line, 0) after join. |
| :global cursor | Cursor at last matched line after :g/pattern/d | Fixed in fork | Fork sets cursor to last matched line (clamped to document end) after line-deleting :g commands. Non-destructive :g leaves cursor where the last sub-command placed it. |
| :s empty | Repeats last pattern with default flags (no /g) | Fixed in fork | Fork’s :s without arguments no longer preserves the /g flag from the previous substitution. |
| gj/gk widgets | Navigates into replaced decorations | Fixed in fork | Fork’s findPosV clamps any multi-document-line jump to ±1 when no fold is present. This handles both replaced widgets (MathJax) and variable-height lines (headings with larger fonts). posAtCoords resolves the horizontal position on the clamped target line. |
| gj/gk column | Preserves character column across lines | Pixel drift | Neovim preserves the character column (curswant) because all terminal characters are monospace. The fork preserves the pixel X coordinate (goalColumn) via posAtCoords, which maps to a different character index on heading lines (wider font). The round-trip (gk gk gj gj) returns to the exact starting column because the pixel X is preserved throughout. See “gk/gj column drift on heading lines” below. |
| gk frontmatter | Navigates into frontmatter like k | Fixed in fork | Fork’s moveByDisplayLines now checks focusBefore on the findPosV result, matching the existing check in moveByLines. The stuckAtBoundary condition uses range.head === startOffset to avoid false positives on wrapped lines — gk navigates wrapped display lines first and only enters properties from the topmost display line. Users who remap k to gk can now enter frontmatter navigation. |

Surround nvim-surround parity gaps

Status: 74 golden comparison tests against nvim-surround (Neovim 0.12.2). 74 pass. The ground truth was shifted from tpope/vim-surround to nvim-surround — nvim-surround is better maintained, has a comprehensive test suite, and is Lua-native (aligned with Neovim’s direction). It implements all tpope/vim-surround behavior plus extensions.

Fixed in this release:

  • Opening bracket ds(/ds[/ds{ now works — findSurroundingBrackets parameter swap fixed
  • Cursor position after ys/yss/visual S now at ch:0 (on the delimiter) — matching nvim-surround
  • ds( on nested parens and multiline content now works
  • cs({ now correctly finds and changes parens to braces with spaces
  • ds} space preservation — closing-bracket forms now preserve inner spaces (opening forms still strip)
  • cs chained operations — _surroundReplacement no longer leaks between different surround operation types
  • cs dot-repeat — csba.. correctly changes nested bracket layers via search position offset. The offset was later found to be applied one iteration too early. It exists so a second or later pass through the count loop starts past the delimiter the previous pass wrote and expands outward; applied to the first pass it only moves the search off the cursor, so a repeat with the cursor on the last character before a closing quote matched that quote against the following pair’s opening quote and produced (test), "test(, )test", "test" where (test), (test), "test", "test" was wanted. It now applies only when the cursor is standing on that delimiter — where changeSurroundPair parks it — so csba.. still walks outward through (((test))) while a cursor resting inside a pair is left alone. (#197)
  • Multiline dsb — cursor clamped to valid line length after bracket deletion
  • Count-prefixed ds/cs — now uses “apply N times” semantics matching nvim-surround (2dsb = delete twice, 3csbr = change all 3 levels)
  • ys with line-crossing motions — ysjb, ys2jB correctly expand to full lines for linewise motions
  • ySS/VSB newline indentation — single-line content no longer gets extra 2-space indent, matching nvim-surround
  • Visual block $ S} — now surrounds each line individually instead of wrapping entire block
  • dsf (delete surrounding function call) — implemented with regex-based function name detection
  • csbBysaBb chain — ys with text object motions (aB, iw) after cs now works. The ys_motion handler directly evaluates text object motions instead of dispatching through the fragile handleKey → evalInput path where clearInputState would lose the selectedCharacter.
  • ys with the plugin’s Markdown text objects — ysi$, ysa$, ysi=, ysi~, ysi_, ysil, ysiC, ysio, ysi, and the rest now work. The ys_motion handler previously called the fork’s built-in text object function directly, which only knows the built-in objects (( ) { } [ ] < > ' " ` b B w W p t s); anything else cancelled the operation and cleared the pending ysi from the chord display. Resolution now matches normal operator-pending — the exact key sequence is looked up in the keymap first, with the built-in object as a fallback, so a registered object that shadows a built-in one (aB is the blockquote object, not the {} block) only wins where it actually matches. (#179)

Remaining deviations (3 cases):

CategoryCountDescription
ys dot-repeat with tag/function2ysiw<tag> and ysiwf dot-repeat works correctly at runtime — the resolved tag/function name is stored in _surroundReplacement and replayed via addSurroundToRange(). However, these operations cannot be reliably tested via WDIO because < and > characters conflict with vim’s angle-bracket notation when dispatched through browser.keys or Vim.handleKey. Verified at fork level (1806/0 tests pass).
ds< semantic difference1Intentional: fork treats < as angle bracket; nvim-surround treats it as tag prompt (no-op)

Fixed (previously listed as deviations):

  • ys dot-repeat with text objects — Fixed for simple delimiters (ysiwb, ysiw", ysaw', ysiw]). Fork stores text object motion characters in lastEditInputState._ysTextObjectMotion and _ysTextObjectChar via the onRepeat callback. During dot-repeat, repeatLastEdit re-evaluates the text object at the current cursor position and applies addSurroundToRange(). Fork tests: 1806/0 (was 1803/3).
  • Tag cst/yst (change/add tag) — Verified working. Fork tests (vim_cst_to_tag, vim_cst_to_char, vim_ysiw_tag, vim_dot_cst) and plugin e2e tests (74 golden + 81 plugin-level) all pass. The original golden data was recorded against vanilla Neovim (no nvim-surround plugin), making golden comparison meaningless for surround. Plugin e2e tests are the definitive verification.

Test coverage: test/specs/vim-builtin/surround-golden.e2e.ts — 74 golden tests. test/specs/surround.e2e.ts — 98 passing, 2 skipped (tag/function dot-repeat — verified at fork level). Fork: 238 surround cases pass; the full fork suite is 1915 passing with one pre-existing failure unrelated to surround (vim_increment_octal, which fails identically on pristine master).

Built-in pairs are overridable. All 19 built-in surround characters used to be refused by registerSurroundPair; they are now rebindable from Lua and vimrc, including the three that carry interactive behaviour (t and f as targets, < as a replacement). An alias resolves to its canonical character first, so overriding ) reaches b. Empty delimiters are rejected, because they would disable the character rather than rebind it. (#197)

gr replace-with-register parity gaps

Status: Core functionality implemented. See src/operators/replace-with-register.ts.

The gr operator implements the three primary mappings from inkarkat/vim-ReplaceWithRegister:

  • ["x]gr{motion} — replace motion range with register contents (characterwise)
  • ["x]grr — replace current line (linewise; operator double-press)
  • {Visual}["x]gr — replace visual selection with register contents

The replaced text is discarded into the black-hole register; the source register is preserved.

Remaining gaps: None.

Fixed:

  • Blockwise visual mode (<C-V> + gr) — Implemented blockwise replacement with register line duplication/truncation and per-line replacements.

Test coverage: test/specs/operators.e2e.ts — 26 passing tests: grr (single, multi-line, count), griw, gr$, grl, gri', gr}, named registers ("agriw, "a3grr), visual gr (charwise, linewise V, blockwise <C-V>), register type coercion (linewise↔charwise), cursor positioning, dot-repeat (griw, grr, 3grr+.), multi-line register expansion, text object at line boundary.

Test-discovered behavioral discrepancies

These were found by translating edge-case tests from Neovim’s legacy test suite and replit/codemirror-vim. Each has a corresponding it.skip() test with a // BUG: comment.

dG leaves trailing newline

Status: Fixed in fork.
Test: test/specs/vim-builtin/operator-combos.e2e.ts — “dG should delete from current line to end of file”

dG from line 2 of a 4-line document produces 'one' instead of 'one\n'. The fork’s operators.delete now expands the anchor to include the preceding newline when deleting linewise to end of file.

iB does not scope to innermost blockquote nesting level

Status: Fixed. Both tree-backed and fallback blockquote text objects select contiguous lines at or above the cursor line’s explicit quote depth. Tree-backed selections exclude CommonMark lazy continuations at lower depth, strip the full nested prefix for inner selections, and preserve the fallback’s newline handling for around selections.

di* operates when cursor is on the delimiter

Status: Fixed. Both tree-backed and fallback inner delimiter objects exclude cursor positions on the delimiter characters; around objects still include them. Tree-backed matching validates the exact delimiter character and run length at both ends and walks outward past incompatible nodes, including the grammar’s nested single-tilde node inside ~~text~~.

Dot-repeat of cw + typed text unreliable (Fixed)

The vim engine correctly records and replays insert mode changes after cw. The original test failure was caused by using browser.keys (DOM events) for insert mode typing instead of vimRawKeys, which dispatches keys through the Vim key handler.

) sentence motion cursor position at end of text (Fixed)

Fixed in fork. The findSentence() forward scan now checks whether the computed fallback position is at or before the starting cursor on the same line, and returns the original position unchanged if so.

n/N search wrap-around unreliable (Fixed)

The vim engine correctly wraps search results. The original test failure was caused by using individual browser.keys calls with pauses for the /foo + Enter + n sequence instead of vimRawKeys, which dispatches the full key sequence through the Vim key handler without timing gaps.

Hint mode in the separate settings window (Obsidian 1.13+)

Status: Platform limitation.

In Obsidian 1.13+, the settings window opens as a separate OS-level Electron BrowserWindow by default. This window runs in its own renderer process, which plugin JavaScript in the main window cannot access. The plugin’s global hotkey listener and hint mode overlay cannot be injected into this window.

Workaround: Disable the separate settings window by unchecking Settings → Interface → Open settings in new window. When settings opens as an in-app modal instead, the plugin’s global hotkey and hint mode work normally — the capture-phase keyboard listener fires before the modal’s scope intercepts events.

Hint mode works in all other contexts: the main window, workspace popout windows (popped-out notes), and any in-app modal (command palette, file switcher, etc.).

Hint mode element selector fragility

Hint mode targets clickable elements using CSS class selectors like .nav-file-title, .workspace-tab-header, .vertical-tab-nav-item, etc. These are Obsidian’s internal CSS classes, not part of the public plugin API. They may change between Obsidian versions. Standard HTML selectors (a[href], button, [role="button"], etc.) are stable.

If hint mode stops labeling certain UI elements after an Obsidian update, the selector list in src/ui/hint-mode.ts may need updating.

Status bar left-alignment

The vim mode indicator and chord display are positioned at the leftmost edge of the status bar via parentElement.insertBefore(el, firstChild) and margin-right: auto. This relies on Obsidian’s status bar being a CSS flexbox container with justify-content: flex-end — if Obsidian changes its status bar layout in a future version, the positioning may break. The powerline ::after pseudo-element (CSS border-triangle) also depends on the status bar’s flex item sizing.

Obsidian native highlights not cleared on Escape (Fixed)

Status: Fixed. Pressing Escape in normal mode now clears Obsidian’s is-flashing highlights (the highlight shown after following an internal link to a heading like [[Note#heading]]). Uses the unofficial editor.removeHighlights('is-flashing') API. (#122)

Chord display reads internal vim.status

The chord display reads adapter.state.vim.status directly from codemirror-vim’s internal state rather than accumulating keystrokes from the vim-keypress event. This is necessary because in Obsidian’s CM6 adapter, vim-keypress fires after command processing — by which point clearInputState has already reset the input buffer for completed commands. Manual accumulation would cause stale keys to persist after single-key commands like j or G.

The mode tracker listens to three events to sync the chord display: vim-mode-change, vim-keypress, and vim-command-done. The vim-command-done listener is needed because Escape in normal mode (cancelling a partial command like d) fires vim-command-done without a mode change or keypress event — without it, the stale chord would remain visible. The vim-keypress handler also clears Obsidian’s native is-flashing highlights when <Esc> is pressed in normal mode.

Chord display breaks during surround sub-state (Fixed)

Status: Fixed. Multi-key surround commands (ysiwb, cs"(, yss", 2ysiw*) now correctly accumulate all pending keystrokes in the chord display. Previously, the chord disappeared after the surround sub-state was entered because processAction called clearInputState (which fires vim-command-done, clearing vim.status) before the surround action set vim.surroundState. Fixed in the codemirror-vim fork by saving and restoring vim.status around clearInputState when the action sets a pending vim.surroundState, and explicitly clearing vim.status when the surround operation completes. (#123)

vim.status is not part of a public API — it is an internal string maintained by the CM6 vim plugin adapter. If Obsidian updates its bundled codemirror-vim and the status accumulation changes, the chord display may stop working or display incorrect values.

DOM keyboard events not routed after settings reload

Status: Confirmed, test workaround in place.

After reloadFeatures() (triggered by toggling any setting in the plugin’s settings tab), browser.keys-style DOM keyboard events may not reach the CM6 vim key handler. The vim engine itself is fully functional — Vim.handleKey() processes all commands correctly, and the user can interact normally by clicking the editor to restore focus. The issue is that the CM6 EditorView’s focus/event-routing state is disrupted by the extension reconfiguration that reloadFeatures() triggers internally.

This does not affect normal usage — clicking the editor or switching tabs restores event routing. It only affects automated testing with WebDriver, where browser.keys dispatches synthetic keyboard events without a preceding click.

EasyMotion visual mode label selection via DOM events

Status: Test infrastructure limitation (1 test skipped).
Test: test/specs/easymotion-comprehensive.e2e.ts — “v + w + label should select text from cursor to target”

When EasyMotion produces only 2 labels (e.g., [a, s]), pressing the label character via browser.keys sends the key through the browser’s DOM event system. The vim key handler processes the key before the EasyMotion waitForLabel DOM listener receives it, so the label press is consumed as a vim command instead of an EasyMotion selection.

This does not affect real user interaction — physical keypresses reach the EasyMotion capture-phase listener (registered with addEventListener('keydown', handler, true)) before the vim handler. It only fails with WebDriver’s synthetic events in specific timing conditions (low label count = single-character labels that also happen to be valid vim commands like s).

The async visual mode selection itself works correctly — the v + f + label test passes because the char-search flow has different timing, and the easymotion-visual.e2e.ts suite (4 tests) passes entirely.

gk/gj column drift on heading lines

Status: Known deviation from Neovim. Pixel-preserving behavior is correct for GUI editors.

When gk/gj crosses a heading line (which Obsidian renders with a larger font), the character column shifts. For example, starting at ch:16 on a body text line and pressing gk to move onto a ### heading line lands at ch:15 instead of ch:16. Neovim preserves the character column exactly (ch:16 → ch:16) because all terminal characters are monospace.

The difference: Neovim’s gk preserves curswant — the desired character column. The fork’s findPosV uses CM6’s posAtCoords to resolve position from goalColumn — the desired pixel X coordinate. In a monospace terminal, these are equivalent. In a proportional-font GUI editor like Obsidian, heading characters are wider, so the same pixel X maps to a smaller character index.

Start chNeovim heading chObsidian heading chΔ (Obsidian)
668−2
1111110
1616151
2121183
2622 (clamped)22 (clamped)0

The round-trip is lossless: gk gk gj gj always returns to the exact starting column (Δ:0) because the pixel X coordinate is preserved throughout the navigation.

This is inherent to CM6’s coordinate-based moveVertically and cannot be fixed without reimplementing vertical navigation in character-column space — which would break correct display-line behavior for wrapped lines (where pixel-based resolution is the only correct approach). The current behavior is consistent with how other GUI vim implementations (VS Code vim, IntelliJ IdeaVim) handle proportional-font vertical navigation.

Golden test coverage: 3 golden comparison cases in test/neovim/golden-data/g-commands.json (gk over heading preserves column, gk over heading then above preserves column, gk gj round-trip preserves column), registered as known deviations in test/neovim/deviations.ts.

Per-mode cursor shapes require bundled fork mode

The per-mode cursor shape settings (block, bar, underline, hollow) only take effect when Obsidian’s built-in Vim mode is disabled. With built-in Vim enabled, Obsidian renders its own block cursor and the plugin has no control over its shape. The set guicursor=... vimrc command is also only effective in bundled fork mode.

Cursor shape dropdowns always disabled in Settings UI (Fixed)

Status: Fixed. The 5 cursor shape dropdowns on the Appearance settings page were permanently disabled even when the bundled fork was active. Root cause: Obsidian’s addSettingTab() calls getSettingDefinitions() immediately and caches the result. In the plugin’s onload(), addSettingTab() ran before createBundledVimExtension(), so the disabled callbacks captured forkActive = false via closure and always returned disabled. Fixed by calling isBundledVimActive() directly inside each disabled callback (evaluated fresh on every refreshDomState()) and calling settingTab.update() after fork activation to refresh the cached definitions. (#128)

Surround operator scope

Status: Complete. All vim-surround features implemented.

The surround operator implements the full vim-surround command set: ds/cs/ys/yss/visual S with all bracket/quote/tag targets, function wrapping (f/F), newline variants (cS/yS/ySS/gS), count support (bracket depth and quote char repeat), insert mode (<C-G>s/<C-G>S), and dot-repeat. Markdown-specific pairs use count-prefix: 2ysiw* → **word**. Custom surround pairs can be defined via Lua (vim.obsidian.surround.set/add) or vimrc (surroundmap), supporting multi-character delimiters with full ys/ds/cs support (#36).

Breaking changes from CM Vim defaults:

  • < in replacement position triggers tag prompting (was angle brackets with spaces). Use > for no-space angle brackets.
  • f/F in replacement position triggers function wrapping (was literal f/F as delimiters).
  • S in visual mode now surrounds instead of substituting (was S → VdO keyToKey).

Lua configuration (init.lua)

Status: Working. Sandboxed Lua 5.3 runtime via a browser-only version of fengari, absorbed into the monorepo at src/lib/fengari/ and converted to TypeScript ESM (originally based on this fork). (#46)

The plugin supports Lua config files (init.lua, .init.lua, etc. — see Config file resolution) as an alternative to vimrc. Enable in Settings → Vim Motions → Vimrc & key bindings → Configuration mode.

Supported APIs

The Lua config runtime (init.lua) supports vim.opt (including guicursor), vim.o, vim.g (including mode_prompt_*), vim.keymap.set, vim.keymap.del, vim.cmd(), vim.vault_name(), vim.tbl_*, vim.split, vim.trim, vim.startswith, vim.endswith, vim.stricmp, vim.inspect, vim.json, vim.schedule, vim.defer_fn, vim.uv, vim.notify (with levels), vim.obsidian/vim.ob (including vim.ob.meta.* (9 functions), vim.ob.fs.* (11 functions), vim.ob.ui.* (4 functions), vim.ob.im.* (4 functions + 2 properties), vim.ob.get_cursor, vim.ob.set_cursor, vim.ob.get_selection, vim.ob.mode, vim.ob.notice, vim.obsidian.keymap.set/del for global keymaps, vim.obsidian.whichkey.set_group/set_label/add for which-key labels, vim.obsidian.cursor.set for cursor shapes, vim.obsidian.modeprompt.set for mode prompts, vim.obsidian.surround.set/del/add for custom surround pairs, vim.obsidian.leader.set/del/add for leader bindings, and vim.obsidian.pick(source, opts?) for the fuzzy picker), vim.env, vim.api.nvim_set_hl, vim.api.nvim_buf_*, and print(). See docs/configuration/lua-config.md for the full reference.

Unsupported Neovim APIs

require(), vim.lsp, vim.treesitter, vim.ui, vim.diagnostic: accessing these produces a clear error message. vim.api is partially supported: nvim_create_user_command, nvim_create_autocmd, nvim_create_augroup, nvim_del_autocmd, nvim_del_augroup_by_name, nvim_clear_autocmds, 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, and nvim_buf_del_keymap are available; other vim.fn is partially supported (see below): unsupported vim.fn.* functions produce a helpful error listing available functions. The Lua runtime is sandboxed: 7 standard libraries are loaded (_G, string, table, math, coroutine, utf8, os). The io, debug, and package libraries are not available. os.execute and os.exit are permanently blocked. Global functions load, dofile, loadfile, require, rawget, rawset, and rawequal are disabled.

Autocmds

19 events supported: InsertEnter, InsertLeave, ModeChanged, BufEnter, BufLeave, BufWritePre, BufWritePost, FocusGained, FocusLost, TextYankPost, CursorMoved, CursorHold, LeafEnter, LeafLeave, FileType, OilEnter, OilLeave, CmdlineEnter, CmdlineLeave. See docs/configuration/lua-config.md for the full reference.

Limitations:

  • All autocmds are non-nested (callbacks cannot trigger other autocmds)
  • buffer option not supported (Obsidian has no buffer numbers)
  • command option not supported (use callback only)
  • nested option not supported
  • buf field in event data is always 0
  • TextYankPost requires bundled fork mode (built-in vim mode OFF). It also does not fire while the Neovim backend owns keys, for the same reason the plugin’s own Lua keymaps do not: the event comes from the bundled engine, which is stood down. The yank highlight feature is unaffected — under the backend it is driven by a Neovim TextYankPost notification instead.

Per-view mode events (#88)

Mode events (InsertEnter, InsertLeave, ModeChanged) fire per-view across all editors — split panes, popover hover-preview editors, and canvas card text inputs — when using the bundled vim fork (recommended setup with built-in vim mode OFF). Built-in vim mode retains active-leaf-only behavior for these events.

Other adapter-dependent events (TextYankPost, CursorMoved, CursorHold, CmdlineEnter, CmdlineLeave) are still active-leaf-only. Fixed. All 5 events now fire per-view via AutocmdEventWatcher CM6 ViewPlugin, following the same pattern as AutocmdModeWatcher. CursorMoved fires independently per view with position-change detection. CursorHold uses a per-view timer with configurable delay. CmdlineEnter/CmdlineLeave route through the per-view watcher but inherently fire on the active adapter only (the dialog opens on the focused editor).

getModeState() returns global state reflecting the most recent mode event from any view, not per-view state. vim.obsidian.mode() reads the active leaf’s mode, not the event source’s mode — if a popover fires InsertEnter, vim.obsidian.mode() may still return 'n' if the active leaf is in normal mode.

Lua dialect: 5.3 here, LuaJIT (5.1) in Neovim

Neovim documents Lua 5.1 as its permanent plugin interface; this runtime is fengari, a Lua 5.3 VM. Where 5.3 offers more than 5.1 (utf8, string.pack, an integer subtype, __gc on tables) the difference is harmless — plugins simply do not use it. What breaks plugins is the reverse: things LuaJIT has that 5.3 removed.

Shimmed in src/lua/engine.ts: the global unpack, loadstring, string.gfind, table.maxn/getn, coroutine.isyieldable, a partial jit table, and the bit library.

getfenv/setfenv remain stubs — getfenv returns _G and setfenv does nothing. They cannot be emulated faithfully in 5.3, which replaced the function-environment model with _ENV. A plugin relying on setfenv to sandbox a chunk will silently not be sandboxed.

LuaJIT FFI is not available

Neovim ships LuaJIT, so require("ffi") works there and plugin authors use it freely to reach internal C symbols the API does not expose. This runtime is fengari, a pure-Lua VM, so there is no FFI to provide and no way to implement one. require("ffi") and require("jit") fail with a message naming LuaJIT rather than reporting a file-read failure.

In practice FFI use is rare and concentrated in “reach past the API” corners — flash.nvim quarantines its uses in hacks.lua. Where a plugin guards the call, it degrades. Where it does not, that code path is unavailable. flash’s get_end_pos is an example: searchpos() reports where a match starts, and flash reads Neovim’s internal search_match_endcol to find where it ends.

Decoration provider is a coalesced approximation

nvim_set_decoration_provider supports on_start, on_buf, on_win, and on_end. on_line and on_range are not implemented and raise a Lua error at registration — CodeMirror has no per-visible-line redraw callback, and silently accepting them would let a plugin believe its per-line decorations were drawn.

ephemeral = true extmarks raise an error for the same reason: without a real redraw cycle they would persist, accumulating stale decorations.

The cycle runs once per animation frame after a document, viewport, geometry, selection, or focus change, so it lags Neovim by up to one frame. It runs only in bundled fork mode; with Obsidian’s built-in vim mode the whole Lua CodeMirror extension set (including extmarks) is not registered. Providers see a single window and buffer, both handle 0.

vim.ui_attach is not implemented

vim.ui.select, input, open, and progress_status are available. vim.ui_attach/vim.ui_detach are not, and are deliberately absent rather than stubbed.

ext_messages is an ownership transfer, not a subscription: a UI that attaches takes over rendering, and Neovim stops. A partial event stream would therefore cause a consumer such as noice.nvim to suppress real notifications and render nothing in their place — invisible message loss, which is worse than the feature being missing. Even a complete stream would not deliver noice, which renders into floating windows that remain stubs.

vim.ui.open is desktop-only; on mobile it returns nil, errmsg, which is Neovim’s own no-handler shape. opts.cmd is rejected.

Extmark priority does not control visual precedence

nvim_buf_set_extmark’s priority orders overlapping decorations deterministically, but does not decide which one is rendered innermost. CodeMirror 6 mark decorations have no z-index, and Decoration.set(ranges, true) re-sorts the input, so CM6’s comparator has the final say on nesting. Two overlapping hl_group marks therefore render in a stable, priority-derived order, but the higher-priority group is not guaranteed to be the visible one. Plugins that layer highlights (flash.nvim layers backdrop / match / label / cursor) may show the wrong colour on overlap.

sign_text, conceal, hl_mode, virt_lines, and url are modelled or absent and are not populated from Lua.

vim.fn.* subset

92 Neovim vim.fn.* functions have real handlers with all async callbacks (89 without them); 39 registered stubs remain. See docs/configuration/lua-config.md for the full list and NEOVIM_API_STATUS.md for source-guarded counts. Known stubs return placeholders or intentionally reject; unknown names raise on read. No ABSENT_NVIM_API_FUNCTIONS tier exists; deliberately absent fields in plain namespaces read nil. Editor-state queries need an active editor.

vim.fn.strwidth is not display width

src/lua/fn.ts still returns s.length from strwidth: JavaScript UTF-16 code units, not screen cells. This is a separate quarantined display-width defect, not fixed by the coordinate work. Do not use it as a coordinate conversion or test oracle; tabs, wide characters and composing marks invalidate that assumption.

Silent string and URI placeholders

The pre-work src/lua/stdlib.ts:1055-1111 audit found twelve silent placeholders. Five now have real coordinate handlers: vim.str_byteindex, vim.str_utfindex, vim.str_utf_start, vim.str_utf_end, vim.str_utf_pos. Seven remain: vim.iconv, vim.uri_decode, vim.uri_encode, vim.uri_from_bufnr, vim.uri_from_fname, vim.uri_to_bufnr, vim.uri_to_fname. They return identities/constants without warning. This distinct silent failure mode is worse to diagnose than a warn-once stub because there is no console trace.

Audited third-party plugins remain blocked

Phase 5/5b’s demand audit leaves both suites BLOCKED; integration Phases 6/7 are cancelled and move to a follow-up plan. These are not passed suites or compatibility claims:

  • mini.surround: no load blockers; core surround-highlight, echospace, getchar-context, input-context-and-form.
  • mini.splitjoin: one load blocker, string-expr-mapping, plus core local-comments. String expression mappings require Vimscript evaluation, which this host does not have. This is an architectural constraint, not a missing function or a to-do item; it may never be unblockable in this host.
  • flash.nvim: terminal LuaJIT FFI blocker, module 'ffi' is not available, proven by test/specs/lua-plugin-flash-diagnostic.e2e.ts. Floating-window or decoration-provider API work cannot unblock it.

The fork’s built-in surround feature is separate from mini.surround. test/fixtures/mini-api-demand.json records the pinned audit; plugin-api-demand.test.ts accounts for Phase 5b’s five measured promotions. Phases 1–3 removed exactly core set-text-bytes, getpos-bytes, extmark-columns and optional get-text-bytes; non-coordinate blockers remain. mini.comment’s existing operation tests now use immutable ref 27a29d6b949b9497f80a0a03421e89fed71d8c37 in test/fixtures/test-plugins.json, pinned in Phase 0. This improves reproducibility, not the breadth of the compatibility claim.

Hybrid loading

Settings (vim.opt) and keymaps (vim.keymap.set) load immediately without an active editor. vim.cmd() calls at load time are queued and executed when the first editor receives focus. vim.cmd() calls from runtime contexts (function-mapped keymaps, autocmd callbacks, timer callbacks, user commands) execute immediately against the active editor. If no editor is active when a runtime vim.cmd() fires, the command is skipped with a console warning. If no init.lua file exists, the loader silently skips (no notice).

Loading order

init.lua loads after vimrc. Both can be used simultaneously — Lua values override vimrc values on conflict. This differs from Neovim, which uses either init.lua or .vimrc, not both.

Function callbacks and Tier 3 functions

Lua function callbacks (vim.keymap.set('n', 'key', function() ... end)) execute at keypress time, not config-load time. vim.cmd(), vim.fn.line('.'), vim.fn.col('.'), and other editor-state-dependent functions work correctly inside callbacks. They error at config-load time because no editor is active (context-aware execution). Leader-prefixed keymaps registered via vim.keymap.set with a desc option automatically appear in the which-key overlay.

vim.cmd() broken at runtime (Fixed)

vim.cmd() called from runtime contexts (function-mapped keymaps, autocmd callbacks, timer callbacks, user commands) silently failed. The handleExCommand callback pushed commands to a pendingExCommands queue that was drained once after initial load — runtime calls pushed to an orphaned array. Fixed by adding a runtimeExHandler that executes commands immediately via vim.handleEx() after load completes. Cleanup on plugin unload prevents stale callbacks. (#49, #27)

vim.keymap.set leader bindings not in which-key (Fixed)

vim.keymap.set("n", "<leader>x", ...) registered in the vim engine but not in LeaderRegistry, so bindings didn’t appear in the which-key overlay. Additionally, luaResult.leaderBindings was returned by the loader but never consumed in main.ts. Fixed by auto-detecting leader prefix in vim.keymap.set and calling onLeaderBinding + onWhichKeyCommandLabel. Buffer-local keymaps (buffer = 0) are excluded from global registration. (#27)

BufEnter for initial file

BufEnter autocmds set in init.lua now fire for the file already open when the plugin loads, via a synthetic BufEnter during activate(). Previously, BufEnter only fired on subsequent file opens.

Limitation: Buffer-local keymaps with function callbacks registered inside a BufEnter autocmd during the initial synthetic fire may be destroyed by the subsequent reloadFeatures() call, which resets the vim keymap. Keymaps registered from BufEnter events triggered by actual file switches (after initial load) work correctly. Workaround: use vim.obsidian.leader.add with string command IDs for buffer-local-like behavior, or use ModeChanged events for per-buffer setup during initial load.

Function-callback keymaps lost after feature reload (Fixed)

Function-callback keymaps from vim.keymap.set were silently destroyed when reloadFeatures() called vim.resetKeymap(). String-RHS keymaps survived because vim.noremap/vim.map entries are stored separately from mapCommand entries. Fixed by moving applyLuaMaps() to run after reloadFeatures(). Additionally, loadLuaConfigForTest() now clears luaActionNames to prevent stale callback references after Lua state destruction.

vim.schedule_wrap + vim.cmd() in timer callbacks

vim.schedule_wrap inside a vim.uv.new_timer callback creates a double-deferred execution chain (timer → setTimeout(0) → callback). vim.cmd() called from this innermost callback may fail silently because the active editor context is lost between the two async boundaries. Workaround: call vim.cmd() directly in the timer callback without vim.schedule_wrap, or use vim.defer_fn instead.

Which-key “leader-only” mode does not detect space as leader (Fixed)

When vim.g.mapleader = " " and vim.opt.whichkey = "leader", the overlay now appears after pressing space. onKeyPressLeaderOnly compares against this.normalizedLeaderKey, which normalizes the literal ' ' to '<Space>', matching the codemirror-vim vimKeyFromEvent output. No fork-side changes are required for this behavior.

executeLuaForTest does not support runtime vim.cmd()

The test-only Lua executor (executeLuaForTest in main.ts) has handleExCommand: () => {} (no-op). vim.cmd() calls through this path silently do nothing. It also lacks onLeaderBinding and runtime handler activation. Use loadLuaConfig() (via loadLuaConfigForTest) for tests that need runtime Lua behavior.

No Lua instruction-count hook on runtime callbacks (Fixed)

All runtime lua_pcall sites (function keymaps, user commands, autocmd handlers, timer callbacks, snippet dynamic nodes) are now wrapped with withInstructionGuard, which sets lua_sethook with LUA_MASKCOUNT before each call and clears it after. The instruction limit is 500,000 for callbacks and 100,000 for snippet nodes. On timeout, a throttled Notice is shown (5-second cooldown to prevent spam) and the error is logged. Obsidian remains responsive.

Known deviations from Neovim

4 deviations registered in test/neovim/deviations.ts:

  • keymap.del + Q: plugin’s built-in Q→@@ mapping persists after Lua unmap — Fixed. reloadFeatures() now re-applies Lua map operations (including unmaps) after re-registering built-in mappings
  • cw + <Esc> in mapped keys: test infrastructure key dispatch difference
  • Visual surround cursor: off-by-one in visual mode
  • Leader key in test: leaderRegistry propagation timing in executeLuaForTest

Bundle size

Fengari fork adds +201KB minified / +65KB gzipped (reduced from +238KB / +79KB after stripping Node.js dependencies). Total plugin size: ~671KB minified (13.4% of the 5000KB soft limit).

Intentionally skipped Lua features

FeatureReason
require() / plugin loadingSecurity — sandboxed environment, no module system (Lua package library stripped in fork)
vim.api.nvim_*69 real implementations / 88 registered stubs / 157 known names. Includes byte offsets, byte cursor/mark reads and synthetic current-window dimensions/identity; handle-zero and coordinate/rendering limitations above remain. Exact source-guarded membership is in NEOVIM_API_STATUS.md; unknown names error on read.
vim.fn.hostname() / vim.fn.getenv()System fingerprinting concern
vim.fn.system() / vim.fn.systemlist()Security — no shell execution in browser sandbox
vim.fn.readfile() / vim.fn.writefile()Use vim.ob.fs.read / vim.ob.fs.readlines for vault file access
vim.fn.map() / vim.fn.filter()Accept Vimscript string expressions, not Lua functions — use vim.tbl_map(fn, tbl) / vim.tbl_filter(fn, tbl) instead
vim.fn.printf()Uses C-style format strings — use Lua’s string.format() instead (identical syntax)

vim.fn functions not yet implemented

92 vim.fn functions have real implementations with all async callbacks, including getwininfo()/wincol(), strchars()/charidx()/byteidx(), line2byte()/byte2line(), charcol()/virtcol()/virtcol2col(), win_getid()/winnr() and deletebufline(). Display columns use the resolved supported window options and measured viewport width, not full terminal-grid emulation. The following functions remain unavailable:

FunctionNotes
bufnr() / bufname()Buffer identity — could map to active file path but Obsidian has no buffer numbering
tabpagenr()Tab number; winnr() now implements the synthetic single-window model
winwidth() / winheight()Editor viewport dimensions
confirm()Confirmation dialog — would require Obsidian modal UI integration
submatch(n)Submatch in :s replacement expression — only useful in Vimscript substitutions

Oil explorer

Status: Stable. Uses embedded editor view (no temp files). Single-directory operations fully functional.

Cross-directory file moves require both directories open

Moving a file from directory A to directory B requires opening both directories in separate oil buffers (dd in one, p in the other, then :w). The diff engine detects cross-buffer moves by matching entry IDs across buffers.

Vim state is per-editor when using bundled vim mode

When Obsidian’s built-in vim is disabled and the plugin provides vim via the bundled fork, each oil view gets its own vim instance. Registers, macros, and ex command history are not shared between the oil editor and regular editors. The embedded editor relies on Obsidian’s registerEditorExtension() injection to receive the vim extension — if the injection fails (e.g., on a leaf that has never hosted a MarkdownView), the ensureVimExtension() safety net in embeddable-editor.ts adds vim via StateEffect.appendConfig.

When built-in vim is enabled, vim state is shared globally through Obsidian’s editor infrastructure. This limitation only affects fork mode.

Oil editor degraded when opened from non-editor context (Fixed)

Status: Fixed. Two changes: (1) openOil() in manager.ts now primes the leaf with a temporary markdown view state before switching to the Oil view type when no MarkdownView is active. This ensures the leaf’s CM6 editor infrastructure (including registerEditorExtension() injections) is bootstrapped before the Oil editor is created. (2) embeddable-editor.ts removes a dead vim extension guard (!builtinVimOn && isBundledVimActive() — always false when using the bundled fork because isVimEnabled() conflated built-in and bundled vim) and adds a post-construction ensureVimExtension() safety net that checks for vim presence via getCM() and appends the extension via StateEffect.appendConfig only if absent.

Hidden files (dotfiles) are view-only

When “Show hidden files” is enabled, Oil discovers dotfiles (.gitignore, .hidden-folder/, etc.) via the Obsidian adapter API. These files appear in the directory listing and can be opened, but renaming, deleting, or moving them via Oil buffer editing may fail because Obsidian’s Vault API does not index dotfiles. Full CRUD operations on hidden files are not yet supported.

Cannot open files/folders from vault root (Fixed)

Status: Fixed. Cache ID desync in discoverAndMergeHidden() caused buffer entry IDs to become permanently out of sync with the cache after the async hidden-file discovery flow. The method called cache.loadDirectory() three times per refresh cycle, each clearing and reassigning IDs. Fixed by passing the expected buffer content as a parameter and eliminating the redundant re-render. (#93)

Title bar does not update when navigating directories (Fixed)

Status: Fixed. setDirectory() and refreshContent() now call leaf.updateHeader() after changing dirPath, signaling Obsidian to re-read getDisplayText(). (#93)

Hidden files toggle (g.) has no effect (Fixed)

Status: Fixed. The ?? operator on the boolean-typed oilShowHiddenFiles setting never fell through to the runtime toggle. Replaced with a showHiddenOverride: boolean | null field that takes priority when set by g.. (#93)

<CR> opens file in new tab instead of same leaf (Fixed)

Status: Fixed. openEntryAtCursor() now uses leaf.openFile() directly on the Oil leaf to replace the Oil view, matching oil.nvim’s default select behavior. <C-t> is available for opening in a new tab. (#93)

<C-t>/<C-s>/<C-h> keybindings do nothing (Fixed)

Status: Fixed. Obsidian’s default hotkeys (Ctrl+T = new tab, Ctrl+S = save, Ctrl+H = search & replace) intercepted these keys at the Electron level before the embeddable editor’s vim handler received them. Fixed by registering these keys (plus <C-l> and <C-c>) on the embeddable editor’s Obsidian Scope, which fires before default hotkeys. Navigation keys blur the editor before navigating so the setActiveLeaf guard allows the new leaf through. (#93)

Ctrl hotkeys broken after closing Oil (Fixed)

Status: Fixed. Closing Oil left the Obsidian Scope (with Oil’s Ctrl+T/S/H/L/C handlers) pushed on the keymap stack, intercepting Ctrl keys on the restored file. Fixed by calling editor.destroy() in OilView.onClose() before removeChild(), which pops the scope. (#93)

Oil loses focus after committing staged changes (Fixed)

Status: Fixed. 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, cancelled, or dismissed via Esc. Two bugs: (1) OilConfirmModal.onClose() never resolved the promise on Esc dismissal, causing commit() to hang permanently. (2) No focusEditor() call after the modal closed. Fixed with a resolved guard in the modal and view.focusEditor() on both confirm and cancel paths. (#100)

Third-party CM6 extensions not available in oil

Extensions registered by other plugins via registerEditorExtension() do not appear in the oil editor. The embedded editor only includes extensions explicitly passed through buildLocalExtensions() — currently the oil conceal extension and (when built-in vim is disabled) the bundled vim extension. Syntax highlighting and markdown rendering from Obsidian’s core are included.

Oil uses undocumented Obsidian internal API

The embedded editor is created by extracting Obsidian’s internal ScrollableMarkdownEditor prototype via app.embedRegistry.embedByExtension.md(). This is an undocumented internal API used by the Kanban plugin (500k+ installs) since 2022 without breakage. A runtime guard produces a descriptive error if the API changes in a future Obsidian update. The oil feature will degrade gracefully (error notice, oil unavailable) rather than crashing.

Oil.nvim parity gaps

The Oil explorer implements a subset of oil.nvim’s features. The following oil.nvim behaviors are not yet implemented:

  • Brace expansion: oil.nvim supports foo.{js,test.js} syntax to create multiple files at once. The plugin requires one file per line.
  • Copy via buffer edit: In oil.nvim, duplicating an existing entry line (same name, new ID) triggers a file copy. The plugin does not support this; use Obsidian’s file explorer for copies.
  • Trash view toggle (g\): oil.nvim can toggle between the file view and a trash view for the current directory. Not applicable — Obsidian manages trash separately.
  • Incremental rendering: oil.nvim renders large directories progressively (25ms/500ms thresholds). The plugin renders all entries at once, which may cause a brief delay for directories with thousands of files.
  • ../ entry: oil.nvim shows a ../ entry as the first line for navigating up. The plugin uses the - keybinding instead.
  • Column display: oil.nvim supports configurable columns (icon, size, permissions, mtime). The plugin shows emoji icons (📁/📄) only.
  • LSP workspace edit integration: oil.nvim fires willRenameFiles/willCreateFiles/willDeleteFiles for LSP clients. The plugin does not integrate with LSP workspace edits.
  • _ (open cwd), ` (:cd), g~ (:tcd): These oil.nvim bindings relate to Neovim’s current working directory concept, which has no equivalent in Obsidian.

Note freezes in Reading Mode after closing Oil (Fixed)

Status: Fixed. Closing Oil now restores the original editor mode (source, live preview, or reading). The mode is captured when Oil opens via MarkdownView.getState() and restored via leaf.openFile(file, { state: previousViewMode }) on close. All close paths (keybindings, ex commands, Lua API) use a unified closeOil() method. (#93)

Cursor focus lost when switching back to Oil tab (Fixed)

Status: Fixed. Switching back to an Oil tab via gT or Obsidian’s tab navigation now re-focuses the embedded editor. OilKeybindingManager.onActiveLeafChange() calls view.focusEditor() when the active leaf is an Oil view. (#93)

:Oil . opens current file’s directory instead of vault root (Fixed)

Status: Fixed. :Oil . and :Oil / now correctly open the vault root. The empty-argument case (:Oil with no args) still opens the current file’s parent directory. Both the vim ex command handler and the global ex command palette are updated. (#93)

Oil temp files visible with oil~ prefix (Fixed)

Status: Fixed. Oil now uses a dedicated view type with an embedded editor. No temporary files are created in the vault.

Dotfiles cannot be used for temp files (Fixed)

Status: Fixed. No longer relevant — Oil no longer creates any files in the vault.

Keybindings are not user-remappable (Implemented)

Status: Implemented. All keybindings across all contexts are user-remappable.

Every keybinding is remappable through one of four mechanisms depending on context:

  • Editor keybindings (motions, actions, operators): All have ex command aliases (e.g., :nextheading, :focuspaneleft, :tablenextcell, :hintactivate). Remap via vim.keymap.set('n', 'key', ':excommand<CR>') in Lua or nmap key :excommand<CR> in vimrc.
  • Oil explorer keybindings: Exposed as ex commands (:oilopen, :oilopentab, :oilopensv, :oilopensh, :oilparent, :oilroot, :oilclose, :oilrefresh, :oiltogglehidden, :oilcyclesort, :oilyankpath, :oilreveal, :oilopenexternal, :oilhelp, :oilpreview) and Lua functions (vim.obsidian.oil.parent(), etc.). Default keys match oil.nvim conventions (<CR> same-leaf, <C-t> new tab, <C-s> vertical split, <C-h> horizontal split, <C-p> preview toggle, <C-c>/q close, gx open external). Visual mode multi-select (V + <CR> opens all selected files). Buffer-local remapping via OilEnter/OilLeave autocmd events.
  • Picker keybindings: Configurable via vim.obsidian.pick_keymap() in Lua. Not available via vimrc (picker operates outside the vim keymap system).
  • Global workspace navigation: Remappable via vim.obsidian.keymap.set/del (Lua) and :gmap/:gunmap/:gmaps (vimrc and ex command line). Each default is tagged with a stable name.

See docs/configuration/remapping.md for the full remapping guide with examples for each context.

Remaining limitations:

  • Which-key integration for oil keybindings (showing oil bindings in the which-key popup) is planned but not yet implemented — Fixed. Oil command labels are registered in the which-key overlay’s commandLabels map. When oil bindings are dynamically mapped (on OilEnter), they appear in vim.getCompletions() and the which-key overlay displays descriptive labels instead of raw ex command strings. When leaving oil, bindings are unmapped and disappear from completions.
  • Help command (g? in oil context) is planned but not yet implemented — Implemented. g? opens a VimInfoModal listing all oil keybindings in a Key/Action table, following the same pattern used by :marks, :buffers, and :registers when the picker is disabled. Dismissible via Escape.

Which-key and g? in non-editor context (Fixed)

Investigation (spike25) found that WhichKeyOverlay.tryAttach() correctly attaches to Oil’s embedded CM6 editor via getCmAdapterFromEditorView(), even when Oil is the only view (no prior MarkdownView). The which-key overlay works in Oil-only contexts.

Which-key “all” mode intercepts multi-key Oil bindings (Fixed)

When which-key mode is set to “All partial keys” and the popup delay is non-zero (default 500ms), pressing g in Oil started a delayed timer. The overlay appeared between the g and the second keystroke (?, ., s, f), disrupting the vim key sequence completion. Fixed by bypassing the popup delay timer when the active view is an OilView — the overlay shows immediately (matching delay=0 behavior), which allows the vim engine to process multi-key bindings (g?, g., gs, gf) without interference. The overlay still appears for partial sequences in Oil, preserving discoverability for operator-pending keys (d, c, y, etc.).

Test coverage: test/specs/oil-which-key.e2e.ts — 4 tests covering g? help modal, g. non-interception, no stale overlay after g?, and leader-mode control.

| vim.lsp.* / vim.treesitter.* | Not applicable to Obsidian |
| Async Lua (coroutine ↔ Promise bridge) | Implemented — src/lua/coroutine-runner.ts yields a Lua coroutine on an async host call and resumes it with the result, with a 10 s timeout and a 16-coroutine limit. vim.schedule, vim.defer_fn and the vim.uv timer subset are available. Snippet f()/d() nodes are deliberately blocked from async |

Vault file reading (Implemented)

vim.ob.fs.read(path) is now available in async-capable callback contexts (keymap callbacks, autocmd handlers, timer callbacks, user commands). The function yields the Lua coroutine internally and resumes when the vault read completes. vim.ob.fs.readlines(path) returns a table of lines. Both functions are catchable with pcall. Async APIs cannot be called from snippet f()/d() nodes or at the top level of init.lua (Phase 2). To read the current file’s content synchronously, vim.api.nvim_buf_get_lines(0, 0, -1, false) remains available.

Test coverage: 12 golden comparison tests (Neovim 0.12.2), 43 integration e2e tests covering settings, keymaps, error recovery (syntax/runtime/infinite loop), conditional config, coexistence with vimrc, disabled state, runtime vim.cmd() execution (8 tests), leader binding + which-key integration (9 tests), space-as-leader (7 tests), and documentation example validation (10 tests).

Marks

Status: Working. Gutter indicators, global mark persistence, grouped picker.

Vim marks (m{a-z}, '{a-z}) work via codemirror-vim. The plugin adds three enhancements:

  • Gutter indicators: Mark letters appear in the gutter area next to marked lines using Decoration.line() with a data-vim-marks attribute and CSS ::after pseudo-element. Zero layout shift — marks overlay the existing gutter without adding a column. Toggle via enableMarkGutter setting (default: on).
  • Global mark persistence: Marks A–Z are stored in plugin settings (persistedMarks array) with file path and cursor position. Saved via 30-second polling interval with dirty-flag check, plus immediate save on onunload(). Marks survive file closes and plugin restarts.
  • Grouped marks picker: :marks shows marks grouped under “Buffer marks” and “Global marks” headers. Global marks display the target file path. Cross-file navigation opens the target file and positions the cursor.

Limitations

  • Special marks not in picker — Fixed. Special marks (', ., <, >) are now shown in the :marks picker under a “Special marks” group. They are read from cm.state.vim.marks like buffer marks.
  • Global mark file rename — Fixed. MarkStore.renamePath() is called from the vault.on('rename') handler, and MarkStore.removeByPath() from vault.on('delete'), matching the existing harpoon and fold persistence patterns.
  • Marks set outside vim command pipeline — marks created programmatically (not via m{char}) won’t trigger gutter refresh until the next vim command fires vim-command-done.
  • Gutter refresh mechanism — the gutter reads cm.state.vim.marks on each vim-command-done event. Position tracking through document edits uses Decoration.line() position mapping (set.map(tr.changes)), not polling.

Test coverage: test/specs/marks-gutter.e2e.ts (6 tests), test/specs/marks-picker.e2e.ts (8 tests).

Harpoon file pinning

Status: Working. Pin files to numbered slots with cursor tracking and persistence.

Pin files to numbered slots (<leader>1–<leader>9) for instant switching. Cursor position is tracked per-pinned-file via active-leaf-change departure-cursor capture and restored on navigation. Pins are stored in VimMotionsSettings.harpoonPins with 30-second dirty-flag save interval. File renames auto-update via vault.on('rename'); file deletes auto-remove via vault.on('delete').

Limitations

  • No editable menu — harpoon v2’s floating editable buffer (reorder/remove by editing lines) is deferred. V1 uses the picker + add/remove commands.
  • Navigation opens in current pane — getLeaf(false) replaces the current buffer (matching harpoon v2). Users expecting new-tab behavior should use the picker’s <C-t> for tab-open.
  • Cursor tracking is per-switch — cursor position is captured when you leave a pinned file. Positions are not tracked continuously (no CursorMoved listener). If the plugin crashes, the last captured position may be stale by up to one editing session.
  • Non-markdown cursor restore — pinning works for any file type, but cursor position is only restored for MarkdownView files (PDFs, canvas, images don’t have a text cursor).
  • Sparse slot arrays — removing a pin sets its slot to null without shifting other slots. Slot numbers are stable (pin 3 stays pin 3 even if pin 2 is removed). Trailing nulls are trimmed.

Picker / Fuzzy finder

Status: Working. Unified picker with 13 sources, preview pane, live grep, and frecency scoring.

The picker uses a telescope.nvim-inspired visual presentation: monospace fonts, compact item density, accent-tinted selection, and floating border titles showing the source name (e.g. “Files”), “Results”, and “Preview” on each section’s top border. All colors use Obsidian CSS variables (--font-monospace, --text-muted, --text-accent, --interactive-accent-hsl, --modal-background, --color-accent) for full light/dark theme compatibility. The presentation matches the which-key overlay’s terminal aesthetic. This Neovim-style visual language extends to all plugin modals: SuggestModal subclasses (GlobalExCommandModal, OutlineModal, SearchResultsModal, ContextActionsModal) use the prompt-modal pattern (transparent container, accent border, floating title, two-column suggestion rows with label + description), and Modal subclasses (VimInfoModal, OilConfirmModal) use the info-modal pattern (accent-bordered inner wrapper, floating title, hidden Obsidian chrome).

The picker supports two fuzzy matching engines selectable via Settings → Vim Motions → Picker matching engine:

  • uFuzzy (default): Pure JavaScript matcher (7.5KB) with filename-aware ranking. Prefers exact filename matches over partial path matches (e.g., Header.tsx ranks above header/utils.ts for query "Header"). Supports typo tolerance via single-error mode, configurable fuzziness, and multi-word queries.
  • obsidian: Obsidian’s built-in prepareFuzzySearch API. Zero bundle cost (maintained by Obsidian). May be slower than uFuzzy on very large vaults — the Obsidian docs note performance issues beyond a few thousand items.

Matching is RegExp-based for grep (with fallback to substring matching for invalid patterns). Live grep debounces at 200ms with generation-based cancellation.

Limitations

  • :grep is fuzzy, not regex — Fixed. :grep now uses JavaScript RegExp for pattern matching, matching Neovim’s :grep behavior (which uses the external grepprg). Invalid regex patterns gracefully fall back to substring matching.
  • :marks shows buffer + global marks — buffer-local marks (a–z) are read from the active editor’s vim state. Global marks (A–Z) are read from the plugin’s persisted MarkStore. :marks in a non-editor view shows only global marks (no active editor for buffer marks).
  • Live grep iterates all files synchronously — cachedRead() is fast but iterating 10K+ files on each keystroke (debounced) may cause brief UI pauses on very large vaults. MAX_RESULTS=100 cap limits result set size.
  • Frecency persistence — frecency data is stored in plugin settings via saveData(), debounced to 30 seconds. Data loss on crash is possible for the last 30 seconds of interactions.
  • Preview pane rendering — full-file previews (files, buffers, recent) are rendered through MarkdownRenderer.render(), displaying headings, formatting, code blocks, images, and links with non-interactive links. Positional previews (grep, live grep, headings, marks) use monospace plain text with a line-number gutter that highlights the target line — raw text ensures uniform line heights so the gutter stays aligned (markdown rendering produces variable-height headings/blocks that cause drift). Frontmatter is excluded from positional previews since MarkdownRenderer strips it, which would otherwise misalign the gutter. The picker modal uses a fixed height (50vh) to prevent layout shifts. Plain-string previews (commands, registers) remain as raw text.
  • Preview hidden on mobile — @media (max-width: 600px) hides the preview pane entirely.
  • Tags picker has no preview — selecting a tag opens a sub-picker showing files with that tag.
  • uFuzzy unicode mode — adds ~2.5KB over the base library size for broader language support (CJK, Cyrillic, accented characters).

Bundle size impact

uFuzzy adds +17.5KB. Combined with picker UI code, the picker subsystem adds ~50KB to the production bundle.

E2E test infrastructure weaknesses

Status: Partially addressed.

The e2e test suite had ~47 tests that did not reliably detect the regressions they claimed to guard against. A feature could be deleted or broken and these tests would still pass.

Fixed (this release):

  • 26 golden spec files now have else { throw } guards on SUITES.find() — if a suite name is renamed in test-definitions.ts but not in the spec file, the test runner produces an explicit failure instead of silently generating zero tests
  • Golden mode comparison added to testWithNeovim() — golden data already contained mode values but the CI comparison path only checked content and cursor. Mode mismatches are now caught
  • 33 deviations classified with a category field (intentional, infra-limitation, upstream-bug, upstream-unsupported, recording-issue). findDeviation() export added. [INFRA-SKIP] console warnings emitted for infra-limitation deviations so CI output shows how many tests are silently skipped due to infrastructure limitations
  • 8 undo-tree tests strengthened with content assertions (previously only checked mode === 'normal')
  • 4 ex-command tests (:undo, :redo, :yank, :nohlsearch) strengthened with behavioral assertions. 16 workspace-layout ex-command tests renamed with [crash-guard] prefix to make their tier explicit
  • 6 vimrc mapping tests strengthened with cursor movement verification (previously only called assertPluginLoaded())
  • 1 tautological assertion fixed (toBeGreaterThanOrEqual(0) → toBe(0) for undo tree branch count)

Remaining weaknesses:

  • 15 deviation-masked operations effectively untested: When isKnownDeviation(name) is true, testWithNeovim() skips ALL comparison. 15 remain as infra-limitation deviations: gh/gH select mode (6 tests — spike confirmed handleKey cannot enter select mode via g+h dispatch), N after / search (1 test — CM6 search panel timing), <C-a> re-insert previous insert (1 test), dgn/cgn search-match operators (4 tests), <C-G>u insert undo break (1 test), and 0<C-D>/^<C-D> insert indent deletion (2 tests). Previously 10 — resolved 3: V3j+J, vip+d, v+r, v+aw+d fixed via useHandleKey flag + vimHandleKeys helper (dispatches all keys through Vim.handleKey() synchronously, bypassing DOM event timing); lua nmap change word fixed via key-string encoding (<Esc> literal → \x1b byte); lua leader key mapping reclassified to upstream-bug (leaderRegistry propagation timing, not test dispatch); vt.+d and v$+d exposed as genuine upstream-bug behavioral deviations (visual-mode t range and v$d cursor position differ from Neovim)
  • 3 vimrc set option tests (lines 109, 116, 121 in vimrc.e2e.ts) remain as assertPluginLoaded()-only. Root cause confirmed: the vimrc I/O timing issue (documented under set textwidth via vimrc). SideEffectOpt options (clipboard, expandtab, textwidth) are applied via onSettingOverride → applySettingOverride → this.settings[key] = value, but saveSettings() strips these overrides using preVimrcSettings, and the initializing flag during onload() gates the override chain. By the time the test reads plugin.settings.clipboard, it returns '' (the pre-vimrc default). Vim.getOption('clipboard') also returns ''. The vimrcOverrides Map is empty by test time. The Lua vim.opt equivalent is verified and passing in lua-config.e2e.ts — the Lua path works because loadLuaConfigForTest() runs the override chain synchronously without the initializing/saveSettings stripping
  • :changes ex command test remains a crash-guard. The correct modal selector is .vim-motions-info-modal … the VimInfoModal.open() call completes without error but no .vim-motions-info-modal appears in the document. Root cause likely related to the ex-commands test’s handleEx wrapper or test state (Fixed) — the diagnosis above was wrong, and the crash-guard is why. VimInfoModal.open() was never called: :changes was registered only in setupVimSubsystems(), and reloadFeatures() tears every registration down and rebuilds only those registered through registerExCommands(). Ex teardown did not remove the command — it re-defined it as noopEx — so :changes stayed recognized (the fork never reported Not an editor command, nothing threw) while doing nothing at all. That is indistinguishable from an empty change list, which is what made the wrapper look like the culprit. Fixed by calling the fork’s undefineEx() on teardown and registering :changes in the rebuilt path. The test now asserts the modal title is absent before the call and present after
  • :e! and :update ex command tests remain crash-guards. Confirmed: setupEditor() uses view.editor.setValue(text) which sets in-memory content only … :e! reverts to the last saved disk state, not the setupEditor content (Fixed) — also misdiagnosed. :e! was not reverting to anything: the fork’s parseInput_ ends a command name at the first non-word character, so :edit! parsed as command edit with argument ! and the separately registered defineEx('edit!', …) handler was unreachable dead code. The command created and opened a junk note named !.md and emptied the buffer. :update was a genuinely awkward assertion, but for a different reason than recorded: Obsidian’s idle autosave reaches the same on-disk state within ~2 s and masks a completely broken save, so the test now asserts the dispatched Obsidian command id instead of file content
  • Golden comparison does not check register state or visual sub-mode type — compareStates() compares content, cursor, mode (mode comparison added this release). The golden schema (GoldenCase) and recording infrastructure now capture registers (unnamed register text + linewise flag) and visualMode (charwise/linewise/blockwise) — 24 of 29 golden files include these new fields. However, register comparison is disabled at runtime because register state leaks between tests within the same Obsidian session (each testWithNeovim test shares the same editor instance, so registers from previous tests persist). Neovim golden recording starts each test fresh, producing a clean register state. Enabling register comparison requires either per-test register reset in Obsidian or comparing only tests that explicitly opt in via a flag. The golden data with registers is preserved for future use

s (substitute) test failure (Fixed)

Status: Fixed. Not a code regression — test vault data.json had flashJumpEnabled: true, which mapped s to flash jump mode instead of the built-in substitute (cl). Flash jump tests explicitly enable this setting in their own before() hooks and don’t depend on the data.json value. Fixed by setting flashJumpEnabled: false in data.json and adding a defensive disable in normal-editing.e2e.ts’s before() hook.

Neovim golden test coverage gaps

The plugin verifies Vim behavior against headless Neovim via golden comparison tests (test/neovim/). The following areas of the fork’s test suite are not covered by golden comparison because they cannot be meaningfully verified in a headless Neovim session:

AreaFork testsReason not golden-verifiable
Scroll/viewport (zz, zt, zb, Ctrl-d/u/f/b)9Depend on viewport dimensions and scrollInfo — headless Neovim has no viewport geometry
Fold (zo, zc, za, zf, zd, zE, zm, zr, zj, zk, [z, ]z, zn, zN, zi, zv, zF, zx, zX, zO, zC, zA, zD)32 + 12 goldenFold motions (zj/zk/[z/]z) have Neovim golden tests — zj matches Neovim (hierarchical skip); zk has 1 deviation (trailing blank line boundary); [z/]z have 2 deviations (fold body vs heading boundary). Fold state/recursive ops are plugin-specific (no Neovim golden).
Jumplist (stale marker edge case)1Single test for cross-document marker invalidation — Neovim doesn’t share the CM6 Marker/posFromIndex infrastructure
Cursor rendering (rendered_cursor_position_*)2Test .cm-fat-cursor DOM element pixel position via getBoundingClientRect() — no Neovim equivalent

These areas are covered by the fork’s own browser test suite (1806 tests) but rely on the fork’s test expectations being correct rather than Neovim-verified ground truth.

Golden recorder nvim_feedkeys limitation (fixed)

The golden recorder (test/neovim/client.ts) previously used Neovim’s nvim_feedkeys RPC API with 'tx' flags to send key sequences. This API does not fully execute certain multi-step operations within a single call:

  • Block-insert replication: <C-v> + I/A + text + <Esc> only applied the inserted text to the last selected line instead of all lines in the block. The replication step (which Neovim performs at <Esc> exit from block-insert mode) did not complete before the RPC returned.
  • Visual mode-switch + operator: <C-v>jl then v or V followed by d produced incorrect deletion scope — the mode switch didn’t fully resolve before the operator executed.

This caused the visual-block and upstream-gaps golden suites to contain incorrect expected values that matched nvim_feedkeys behavior rather than interactive Neovim behavior. The 4 failing upstream-gaps tests and all 15 visual-block tests had wrong expectations.

Fixed by using :execute "normal ..." (via nvim.command()) for key sequences containing <C-v>. This executes synchronously within Neovim’s command loop, ensuring all side effects complete. Key sequences without <C-v> (the majority of tests) still use nvim_feedkeys since :normal doesn’t support macro recording (q/@a). An escapeForNormal() helper converts JS control characters to Vim \<...> notation for the :execute string.

Verified with :normal! (headless -c flags), Vimscript feedkeys("...", "tx"), and nvim_feedkeys — only :normal! and :execute "normal ..." produce correct results for block operations.

Input method switching

Status: Working. Desktop only (macOS, Windows, Linux). Requires an external IM switching binary (e.g., macism, im-select, fcitx5-remote, ibus).

The plugin can automatically switch input methods when entering/leaving insert mode across all editor views (split panes, popovers, canvas cards). Enable in Settings → Vim Motions → Input method. The Lua API (vim.obsidian.im) provides programmatic control for advanced use cases. All 7 IM settings are available in both the legacy settings tab (Obsidian <1.13) and the new searchable settings UI (Obsidian 1.13+).

Manual IME switch not preserved across mode changes: Fixed. When a user manually switched input methods during insert mode (e.g., from Vietnamese to English via an OS keyboard shortcut), pressing Esc then i reset the IME to the original input method. The save() method now queries the OS for the actual current IME before caching it, so manual switches are correctly preserved. (#83)

Limitations:

  • Desktop only: Mobile devices do not support child_process and the feature is a no-op. The settings group is hidden on mobile.
  • Command-line and search mode: IM switching auto-wires to CmdlineLeave (switches to normal IM when exiting :, /, or ? prompts). CmdlineEnter does not trigger an IM switch (users may need CJK input for search queries). The global ex command modal (: in non-editor views) does not fire CmdlineEnter/CmdlineLeave.
  • System-wide switching: IM switching is a system-wide OS operation. Switching IM in one Obsidian window affects all windows and applications.
  • Flatpak/Snap: Sandboxed Obsidian installations (Flatpak, Snap) may not have access to IM switching binaries outside the sandbox. Use the AppImage or native package instead.
  • Binary must be pre-installed: The plugin calls an external binary (macism, fcitx5-remote, im-select.exe, etc.) — it does not bundle one. The binary must be installed separately and the full path provided in settings.

Deferred enhancements

The following IM switching improvements are planned but not yet implemented:

  • Platform presets: Implemented. A settings dropdown auto-fills binary path, arguments, and default IM for macism (macOS), im-select (Windows), fcitx5-remote (Linux), and ibus (Linux). Values are editable after selection.
  • Session persistence: Implemented. The per-view IM cache is persisted to plugin settings via saveData() (30-second interval + save on unload). The saved IM is restored on plugin load.
  • :IMToggle/:IMStatus ex commands: Implemented. :IMToggle enables/disables IM switching. :IMStatus shows the current IM identifier via a Notice.
  • Content-type aware switching: IM switching based on cursor context (e.g., auto-switch to English inside math blocks or code blocks) independently of vim mode. Users can implement this today by combining vim.obsidian.im with cursor position checks in Lua autocmds.
  • CmdlineEnter/CmdlineLeave for global ex command modal: The global : modal in non-editor views (Obsidian SuggestModal) does not fire cmdline autocmd events. Only the codemirror-vim editor dialog fires them.
  • CmdlineChanged event: An autocmd event that fires on each keystroke in the command-line prompt. Not needed for IM switching but useful for advanced Lua scripting.
  • cmdline text in event data: Including the actual command text in CmdlineLeave’s event data. Currently only cmdtype (:, /, ?) is provided.
  • Composition listeners on search dialog input: The composition guard currently covers only the editor DOM element. CJK composition in the / search input is not tracked. This is acceptable because CmdlineLeave fires when the dialog closes (abandoning any active composition), but a more complete solution would track composition in the search input too.
  • loadInitLua() parameter refactor: Implemented. The function now takes (app, vim, options?) with a LoadInitLuaOptions interface.

Intentionally not supported

These features are excluded by design and will not be implemented:

FeatureReason
jscommand / jsfile in vimrcSecurity risk — arbitrary JavaScript execution
cmcommand in vimrcBroken in CodeMirror 6, never fixed upstream
Input method switchingBuilt-in since v0.51.0 — see Settings → Vim Motions → Input method
Yank highlightingBuilt-in since v0.47.0 — see Settings → Vim Motions → Vim features → Yank highlight
Reading view navigationUse the vim-keynav plugin
Vim toggle commandUse the vim-toggle plugin
Canvas keyboard navigationCanvas is a different rendering surface without CodeMirror

Picker provider API and pop-out windows

The picker provider API (window.VimMotions.picker) is only available on the main Obsidian window. Pop-out windows have separate window objects and will not have access to the API. External sources registered via the main window work when the picker is opened from the main window.

Bundled picker integrations

Runtime plugin detection

The bundled picker integrations (Omnisearch, Tasks, Dataview) detect target plugins via app.workspace.onLayoutReady() at startup and via reloadFeatures() when integration settings are toggled. Obsidian does not emit events when community plugins are enabled or disabled at runtime (app.plugins has no event emitter — confirmed by runtime inspection of Obsidian v1.12.7). If a user enables Omnisearch/Tasks/Dataview after Vim Motions has loaded, the integration source will not appear until the user toggles the corresponding setting in Settings → Vim Motions → Picker (which triggers reloadFeatures()) or reloads Obsidian.

API stability

The integrations use undocumented or internal APIs from each target plugin. These may change without notice:

  • Omnisearch: Uses globalThis.omnisearch.search(query) — a public but untyped global. Duck-typed at registration time (typeof search === 'function').
  • Tasks: Uses plugin.getTasks() and plugin.getState() on the plugin instance — not part of the official TasksApiV1 (which only exposes modal and toggle methods). The obsidian-tasks-plugin:cache-update workspace event is also undocumented.
  • Dataview: Uses DataviewAPI.pages() — a well-established public API exposed via window.DataviewAPI. The most stable of the three.

All external API calls are wrapped in try/catch. If a target plugin changes its API shape, the integration silently degrades to empty results with a console warning.

Deferred features

The following are intentionally not implemented in v1:

FeatureRationale
Dataview DQL query executionThe picker is a navigation tool, not a query editor. DQL is complex and better served by Dataview’s own code blocks. May be added as a separate dataview-query source if users request it.
Task creation/editing from pickerWrite operations are out of scope for picker sources. Users can jump to the task and edit in-place. Tasks plugin’s apiV1.createTaskLineModal() and editTaskLineModal() could be integrated as picker actions in a future release.
Multiple sub-sources per pluginSeparate tasks-overdue, tasks-today, dataview-query sources would clutter the meta-picker. Prefer filter modes within a single source (e.g., query prefix ! to show all tasks including completed).
Custom Dataview query in settingsPre-configured DQL filters would require a settings UI for query editing. Users who want filtered pages can use Dataview’s own query blocks or write a custom provider via the picker API.
Task filter modesThe tasks source currently shows all incomplete tasks. Modes like “due today”, “overdue”, or “all including completed” could be added via query prefix or a settings dropdown.
Version pinning for target pluginsThe integrations duck-type API methods rather than checking version strings. If a breaking change occurs, the try/catch wrapper prevents crashes. Pinning would require maintaining a compatibility matrix.

Snippets

Dynamic snippet limitations

  • Dynamic snippet f() and d() nodes have a 50ms debounce on recomputation. Very rapid typing may show stale computed values for a brief moment.
  • Lua function execution is time-guarded at 100ms per recomputation cycle. Functions exceeding this limit are skipped for that cycle.
  • Dynamic snippets triggered via the completion menu expand with their static body only — the dynamic context is not activated. Use Tab expansion for full dynamic behavior.
  • Nested d() nodes (dynamic nodes inside dynamic nodes) are not supported.
  • User snippet directory scanning requires desktop — vault-relative paths work on mobile, but absolute paths and ~ expansion are desktop-only.

Snippet variable limitations

  • $CLIPBOARD on mobile — navigator.clipboard.readText() may be unavailable or permission-denied on mobile platforms and non-secure contexts. $CLIPBOARD resolves to '' silently in these cases. On desktop, the clipboard is cached on window focus and visibilitychange events and read synchronously at expansion time. Intra-app vim y/d operations do not trigger the cache refresh — $CLIPBOARD reflects the system clipboard at the last focus/visibility event.
  • $TM_SELECTED_TEXT / $VISUAL in tab-expand mode — tab expansion requires an empty selection (tab-expand.ts returns false when selection is non-empty). $TM_SELECTED_TEXT and $VISUAL always resolve to '' in tab-expand mode. Use the :snippet command in visual mode to expand snippets that wrap the selection. Visual line mode (V) and charwise visual mode (v) are both supported. Visual block mode (<C-v>) captures the bounding range text.
  • Tabstop navigation after visual :snippet — after expanding a snippet from visual mode via :snippet, vim is in normal mode (not insert mode). Tabstop navigation via Tab requires entering insert mode first. This is a pre-existing behavior of the ex-command pipeline — exitVisualMode() returns to normal mode before the handler runs. Snippets expanded from insert mode (tab-expand or completion) are unaffected.
  • Picker snippet expansion does not capture visual selection — the picker-based snippet expansion (picker-source.ts) reads view.state.selection.main which is collapsed after visual mode exit. The :snippet command uses vim marks to recover the visual selection; the picker does not have access to the cm adapter. This is a latent issue — the picker is typically invoked from normal mode.
  • snip.env for Lua f()/d() callbacks — deferred. The current f(args, parent) / d(args, parent, old_state) callback signatures do not carry environment variables. LuaSnip exposes snip.env.TM_SELECTED_TEXT, snip.env.LS_SELECT_RAW, etc. Adding this requires changes to dynamic-bridge.ts and the Lua function invocation protocol.
  • $LINE_COMMENT / $BLOCK_COMMENT_START / $BLOCK_COMMENT_END — deferred. These require cursor-context-aware language detection for code blocks. The simple case (%% always for Markdown) is trivial but not useful inside code blocks where //, /* */, #, etc. would be expected.

Tabstop placement limitations

A tabstop number may be repeated, and every occurrence is linked, as the LSP snippet specification requires — $1 *a$2* *b$2* $0 puts a cursor in both $2 positions and typing updates both. This is VS Code’s and CodeMirror’s presentation of that rule; Neovim’s vim.snippet shows one cursor and mirrors the rest, so the resulting text agrees but the cursor count does not. Neovim additionally rejects a snippet whose repeated ${2:…} placeholders carry different default text, where each default here is inserted as written.

  • A tabstop inside a table does not work in Live Preview. Obsidian renders a Markdown table as an interactive widget that owns the region, so a tabstop landing between the pipes is inside a replaced range. The jump itself is placed correctly, but the next keystroke goes to the widget rather than the tabstop — with |${2:---}|${2:---}| a typed character was measured landing on an entirely different line while the selection stayed on the placeholders. This is not reachable from a transaction filter the way the emphasis case was: nothing can put a CodeMirror cursor inside a widget-replaced range. This remains true of the default native mode, where Obsidian’s per-cell editor owns the keys. There are now two workarounds. Source mode, where both forms behave exactly as Neovim would: |abc|def$2| takes the typed character into the cell, and |${2:---}|${2:---}| becomes |z|z|. And set tablewidget=owned, where the plugin owns the surface: expansion, Tab, Shift+Tab and Escape all work in a cell, and the active tabstop is selected there. Expansion in owned did not work either until it was fixed — measured, wla<Tab> left the literal prefix | wlaaa | 11 | and opened no session, because the nested cell editor carries no snippet extension and the parent’s keymaps are unreachable while it holds focus. Two conversions Obsidian’s own table editor performs are still missing in owned, so a body containing a literal | opens an extra column and one containing a newline does not survive. set tablewidget=raw is not a workaround — see the table-widget section below. (#198)

Ex command snippet expansion (Fixed)

:snippet <name> and :snippets (picker) commands are registered but expansion via the test harness’s Vim.handleEx() bridge does not produce visible results. Fixed. The commands were silently broken after any reloadFeatures() cycle (vimrc load, Lua config load, settings change). registerSnippetCommands() was only called in onload(), but reloadFeatures() calls unregisterAll() which replaced all snippet ex commands with no-ops and never re-registered them. The Picker-based snippet insertion was unaffected (separate pickerRegistry). Fixed by adding registerSnippetCommands() to reloadFeatures(). (#95)

Vim keybindings in text areas

Status: Experimental (disabled by default). (#69)

When enabled (Settings → Vim Motions → Vim features → Vim keybindings in text areas), focused <textarea> elements are replaced with a vim-enabled CodeMirror 6 editor overlay. The editor starts in insert mode — typing works immediately. Press Escape to enter normal mode for full vim editing (motions, operators, text objects, ex commands). A second Escape tears down the overlay and returns focus to the original textarea within the modal — the modal itself stays open. Content is synced back to the hidden textarea continuously (100ms debounce) with synthetic input and change events for host plugin compatibility, plus a final flush on teardown.

Desktop only. Configurable via vim.opt.vimtextareas = true in Lua or set vimtextareas in vimrc.

Sizing

The CM6 overlay uses adaptive height calculation to match the original textarea’s dimensions. The wrapper’s minHeight is set to the largest of the textarea’s CSS height, its scrollHeight (actual content height), and a 100px floor. The maxHeight is capped at max(effectiveHeight, 50vh) — the overlay can grow with content up to half the viewport, then scrolls. The wrapper uses overflow: auto so content exceeding maxHeight gets a scrollbar.

Textarea vim overlay height collapses to near-zero — Fixed. The 0.60.1 fix for unbounded growth locked height + maxHeight to the textarea’s computed height, which could be very small for textareas with dynamic height (height: auto or content-dependent sizing). Replaced with the adaptive minHeight/maxHeight approach described above. (#69)

Textarea vim overlay grows unbounded with content — Fixed in 0.60.1 (replaced with the adaptive height approach above). (#69)

Scope

  • Only <textarea> elements are replaced. <input>, <select>, and contenteditable elements are not affected.
  • The plugin’s own UI elements (picker, oil explorer, vim command-line panel) are never replaced.
  • Disabled, readonly, and textareas inside existing CM6 editors or table cell editors are skipped.

Content sync

Content is synced from the CM6 overlay to the hidden textarea via a debounced timer (100ms). A final syncNow() flush is performed in teardownActive() before the editor is destroyed, ensuring no edits are lost on rapid teardown (e.g., hint-mode clicking Save while a sync is pending).

Textarea content not synced when modal closed via hint mode — Fixed. The teardownActive() method cancelled the pending sync timer and destroyed the editor without flushing. When the MutationObserver detected modal removal (e.g., after clicking Save via hint mode f), the debounced sync never completed and the host plugin read stale textarea.value. Now syncNow() is called before editor.destroy() in all teardown paths. (#69)

Escape behavior

The Escape key follows a symmetric context stack: modal → vim overlay → modal.

  1. Insert mode → Escape: Enters normal mode within the overlay (vim handles it).
  2. Normal mode → Escape: Syncs content, tears down the overlay, focuses the original textarea. The modal stays open — the user can continue interacting with the modal or press Escape again to close it via the host plugin’s own handler.

Second Escape closes the parent modal — Changed. Previously, the second Escape re-dispatched a synthetic Escape keydown to the parent UI after teardown, which closed the host modal (e.g., Spaced Repetition’s edit dialog) and could cause data loss. Now the overlay simply returns focus to the modal context without propagating the key event. (#69)

Escape in hint mode exits the embedded editor — Fixed. When hint mode (or EasyMotion/flash) was active inside an embedded vim editor, pressing Escape to dismiss the overlay also triggered the embedded editor’s Scope-level Escape handler, which called onEscape() because isVimIdle() returned true (hint mode is a plugin-level overlay, not a vim state). The Scope handler now checks isHintModeActive(), isEasyMotionActive(), and isFlashActive() before evaluating isVimIdle(). (#126)

Escape exit immediately re-activates overlay in insert mode — Fixed. After teardown, originalEl.focus() triggered the focusin listener which re-created the overlay after 150ms. Now a recentlyExited guard (via WeakRef + 250ms cooldown) suppresses re-activation for the textarea that was just exited. The textarea can be re-activated by clicking into it again after the cooldown. (#69)

Limitations

  • <input> elements not supported — only <textarea> elements are replaced. Inputs have too many conflicts with host plugin keyboard handling (Enter to submit, Tab to navigate, picker keybindings).
  • No contenteditable support — contenteditable divs conflict with CM6 internals (which uses contenteditable itself).
  • No <iframe> support — cross-origin iframe textareas are inaccessible; same-origin iframes would need per-document observer installation.
  • Framework re-render conflicts — plugins using React, Svelte, or other frameworks may re-render the textarea, removing the CM6 overlay. The manager detects removal but does not retry replacement.
  • Programmatic value changes not detected — if a host plugin sets textarea.value programmatically while the CM6 overlay is active, the overlay does not pick up the change. The synced value on blur will overwrite the programmatic change.
  • Popout windows not supported — the focusin listener is installed on the main document only. Textareas in popout windows are not detected.
  • maxlength not enforced — textareas with a maxlength attribute are not constrained in the CM6 overlay. Content exceeding maxlength will be truncated on sync-back by the browser.
  • Source-mode markdown highlighting — the CM6 overlay uses Obsidian’s source-mode rendering, which applies markdown syntax highlighting. This is cosmetic and does not affect the synced content.
  • workspace.activeEditor not set — the overlay uses skipActiveEditor: true to avoid interfering with Obsidian’s editor tracking. Plugins that check workspace.activeEditor will not see the textarea overlay as the active editor.

Dynamic node (d()) test registration timing

Lua-defined d() snippets registered via loadLuaConfig in e2e tests may not appear in the snippet registry because reloadFeatures() called after Lua config load is short-circuited by the autocmd manager’s isFiring() guard. The snippet registry IS rebuilt directly in loadLuaConfigInternal (bypassing reloadFeatures), but d() snippets defined in a test-specific loadLuaConfig call may not trigger this path correctly. The f() node tests pass because they benefit from the direct registry rebuild added to address this issue. This is a test lifecycle timing issue, not a runtime bug — real users defining d() snippets in .obsidian.init.lua will have them loaded correctly during normal plugin initialization.

Fengari improvement opportunities

The plugin uses a browser-only version of fengari for the Lua 5.3 runtime, absorbed into the monorepo at src/lib/fengari/ and converted to TypeScript ESM. The implementation strips all Node.js dependencies but inherits several upstream limitations and introduces its own constraints. This section tracks potential improvements to the runtime that would expand the Lua API surface, improve spec compliance, or unlock new features.

See src/lib/fengari/DIFFERENCES.md for the full list of changes from upstream.

1. Coroutine↔Promise bridge (async Lua execution)

Status: Implemented. Callback contexts (keymap, autocmd, timer, user command) are async-capable through src/lua/coroutine-runner.ts, and require() resolves synchronously from the in-memory snapshot in src/lua/module-snapshot.ts, so a lazy require inside a vim.keymap.set callback works. Init.lua itself is still loaded synchronously.

Current state: The fengari Lua VM is synchronous — lua_pcall runs Lua code to completion before returning to JS. Obsidian’s vault API (app.vault.read(), app.vault.cachedRead()) is asynchronous (returns Promises). This mismatch blocks:

  • vim.ob.fs.read(path) — reading vault files from Lua (documented in “Vault file reading” limitation above)
  • Async require() — loading multi-file Lua configs from the vault
  • Future HTTP/fetch APIs
  • Chaining multiple async operations in Lua

Approach: Fengari supports lua_yieldk and lua_resume. The pattern would be:

  1. Lua code calls a C-function (e.g., vim.ob.fs.read) that yields the current coroutine
  2. The JS host receives the yield with a Promise attached
  3. The JS host awaits the Promise
  4. The JS host calls lua_resume with the resolved value
  5. Lua code continues as if the function returned synchronously

Challenges:

  • Restructuring engine.ts execution from “run to completion” to “run, yield on async, resume when ready”
  • All Lua code that calls async APIs must run inside a coroutine (top-level init.lua would need implicit wrapping)
  • Error handling across the yield/resume boundary
  • Instruction count hooks (lua_sethook with LUA_MASKCOUNT) must persist across yield/resume cycles
  • Interaction with vim.schedule / vim.defer_fn / vim.uv timer callbacks that already use JS async primitives

Blocks: Async file reading, require() for vault Lua modules, HTTP APIs, streaming operations.

2. Custom require() for vault Lua files

Status: Implemented. Users can split init.lua config across multiple files.

require('mymodule') loads .obsidian/lua/mymodule.lua from the vault. Dot-separated names resolve to subdirectories (require('utils.strings') → .obsidian/lua/utils/strings.lua). Modules are cached in package.loaded — second require returns the same table. Circular requires are detected via a sentinel value in package.loaded.

load(chunk) is re-enabled as a sandboxed version (string compilation only, no file access). dofile and loadfile remain disabled.

Security: module names containing .., absolute paths, or null bytes are rejected with "path traversal not allowed".

-- .obsidian/lua/keymaps.lua
local M = {}
function M.setup()
    vim.keymap.set('n', '<leader>f', ':find<CR>')
end
return M
 
-- init.lua
local keymaps = require('keymaps')
keymaps.setup()

3. 32-bit integer limitation (Widened to 53-bit)

Status: Implemented. Integers widened from 32-bit to 53-bit using JavaScript Number precision. math.maxinteger = 9007199254740991 (2^53 - 1).

What changed:

  • LUA_MAXINTEGER / LUA_MININTEGER widened to ±(2^53 - 1) (symmetric bounds)
  • All |0 arithmetic truncation removed from the VM, lobject, lbaselib, lmathlib
  • string.pack/unpack SZINT changed from 4 to 8 — string.packsize("j") returns 8
  • Table keying, API validation, and string parsing updated for 53-bit range
  • sprintf-js dependency replaced with custom formatter (zero runtime dependencies)

Remaining limitations:

  • Bitwise operations (&, |, ^, ~, <<, >>) remain 32-bit — this is a JavaScript platform limitation. Values > 2^31 are silently truncated in bitwise ops.
  • Multiplication precision: a * b where both operands > 2^26 may exceed 2^53, silently losing precision. Standard Lua wraps via 2’s complement; this fork loses bits.
  • math.ult semantics may differ from standard Lua for negative inputs due to symmetric (not 2’s complement) bounds.
  • Hex string parsing (tonumber("0x...", 16)) for values > 2^53 may lose precision (no explicit overflow check, matching PUC-Rio Lua design).
  • vim.uv.hrtime() uses lua_pushnumber (float), not lua_pushinteger. It already had 53-bit precision before this change — the integer widening does not affect hrtime. The previous claim about “2.1 second overflow” was inaccurate.

4. __gc metamethods via FinalizationRegistry

Status: Implemented (userdata only). Tables with __gc are not finalized.

__gc metamethods on userdata are invoked via JavaScript’s FinalizationRegistry. When userdata with a __gc metamethod becomes unreachable from JavaScript, the FinalizationRegistry callback queues the finalizer. The queue is drained at three points: (1) when the outermost luaD_pcall returns (VM idle), (2) when collectgarbage("collect") is called, (3) during lua_close (plugin unload). Finalizer errors are silently swallowed (PUC-Rio semantics). Finalization order is unspecified. __gc cannot yield.

Remaining limitations:

  • Timer handles (vim.uv.new_timer()) are Lua tables, not userdata — __gc does not help with timer cleanup. Use timer:close() explicitly, or rely on TimerManager.destroyAll() at plugin unload.
  • __gc on tables is not supported (userdata only).
  • Finalization timing is non-deterministic — FinalizationRegistry callbacks fire between event loop turns, not during synchronous Lua execution.
  • Finalization order is unspecified — FinalizationRegistry provides no ordering guarantees.

5. JavaScript RegExp exposed to Lua

Status: Implemented. ECMAScript regex is available via vim.regex().

Current state: Lua’s built-in string.find / string.match / string.gsub use Lua patterns, which are less powerful than regular expressions. The plugin runs in a browser with native ECMAScript regex — this is an advantage over Neovim, where implementing ECMAScript regex in Lua is impractical (mini.snippets explicitly punted on snippet transforms for this reason).

API: vim.regex(pattern, flags?) returns a table that closes over a JavaScript RegExp instance and exposes:

  • re:match_str(str) / re:match_line(str) → 0-based start, end byte offsets, or nil
  • re:match_pos(str, start?) → match starting from offset (default 0), returns 0-based start, end or nil
  • re:replace(str, repl) → returns new string
  • re:test(str) → boolean
local re = vim.regex("([A-Z][a-z]+)", "g")
local start_idx, end_idx = re:match_str("HelloWorld")
local result = re:replace("HelloWorld", "$1-")

Known limitation: No ReDoS protection is applied. User-supplied patterns can be expensive.

6. Re-enable load() with sandboxing

Status: Implemented (as part of require() support, item 2).

load(chunk) is available for string-only compilation. Returns the compiled function on success, or nil + error message on syntax error. dofile and loadfile remain disabled. The instruction count hook applies to loaded code. The sandboxed load is implemented in src/lua/package.ts.

7. string.format performance (sprintf-js replacement)

Status: Implemented. Custom luaSprintf replaces sprintf-js. The fork now has zero runtime dependencies.

8. Lua error message quality

Status: Implemented. Native JS errors are now extractable via pcall.

The plugin installs a lua_atnativeerror handler that converts native JS errors (TypeError, RangeError, etc.) to Lua strings containing the error .message. Previously, native JS errors thrown inside fengari C functions were pushed as lightuserdata and lost — lua_tolstring returned null, producing generic “Unknown Lua error” messages. The handler is installed plugin-side in engine.ts on the global_State, covering all threads including coroutines. debug.traceback already produces clean Lua-only stack traces (no changes needed).

9. Weak tables via WeakRef

Status: Inherited from upstream. High effort, low-medium impact.

Current state: Weak tables (setmetatable({}, {__mode = 'v'}) or __mode = 'k') are not supported. Tables always hold strong references. This breaks idiomatic Lua patterns for caches, observers, and memoization.

Approach: Use JavaScript’s WeakRef and FinalizationRegistry to implement weak reference semantics. Every Lua value stored in a weak table would be wrapped in a WeakRef. This is architecturally complex — the performance implications of wrapping every table value need investigation.

Blocks: Idiomatic Lua cache patterns, observer patterns, memoization.

10. collectgarbage("count") diagnostic

Status: Implemented. All collectgarbage modes return safe values without error.

collectgarbage("count") returns 0, 0 (no memory tracking — fengari has no GC). collectgarbage("collect") drains the __gc finalizer queue. collectgarbage("isrunning") returns false. All other modes return 0. Previously, ALL modes threw luaL_error("lua_gc not implemented"), crashing any Lua code that called collectgarbage().

Priority summary

#ImprovementEffortImpactStatus
1Coroutine↔Promise bridgeHighCritical✅ Implemented
2Custom require() for vault Lua filesMediumHigh✅ Implemented
332-bit → 53-bit integersMediumHigh✅ Implemented
4__gc via FinalizationRegistryMediumMedium✅ Implemented
5JS RegExp exposed to LuaLowMedium✅ Implemented
6Re-enable load() with sandboxingLowMedium✅ Implemented
7sprintf-js replacementLowLow-Med✅ Implemented
8Error message qualityLowMedium✅ Implemented
9Weak tables via WeakRefHighLow-MedNot started
10collectgarbage("count")LowLow✅ Implemented

Yank-ring paste cycling

Status: Implemented. Cycle through numbered register history after pasting.

After p, P, gp, or gP, pressing <C-p> replaces the pasted text with the contents of the next numbered register ("1–"9). <C-n> cycles in the opposite direction. Cycling wraps around. Any non-cycling command cancels the cycling state, after which <C-p>/<C-n> revert to their default k/j behavior.

Enable/disable via Settings → Vim Motions → Vim features → Yank-ring paste cycling or set yankring / set noyankring in vimrc.

Known limitations:

  • Visual-mode paste cycling not supported: Fixed. Cycling now works after visual-mode paste (viw + p + <C-p>). Detects visual paste via anchor/cursor position comparison at snapshot time. Computes paste range via doc-length arithmetic (pasteLen = newDocLen - oldDocLen + selectionLen). Visual block paste is excluded.
  • System clipboard paste timing: The fork’s paste action uses navigator.clipboard.readText() asynchronously for system clipboard registers ("+p). The paste override captures state via setTimeout(0), which may fire before the clipboard Promise resolves. Cycling after system clipboard paste is unreliable.
  • Non-text clipboard content (images) silently ignored by p: Fixed. When clipboard=unnamed or unnamedplus is set and the system clipboard contains non-text content (e.g., an image), p previously did nothing — readText() returned "" and continuePaste() bailed silently. The fork now falls back to document.execCommand('paste') when readText() returns empty or rejects, triggering Obsidian’s native paste pipeline (attachment creation + ![[Pasted image …]] insertion). Covers p, ]p, [p, :put, and explicit "+p. The editor stays in normal mode after the fallback. P/gp/gP (overridden by the host plugin’s pasteFromRegister()) are not affected — they read from internal registers only and do not consult the system clipboard.
  • Workspace navigation dependency: P, gp, gP paste actions are defined by registerWorkspaceNavigation(). If workspace navigation is disabled (enableWorkspaceNav=false), these actions are not registered and cycling after them silently fails. Cycling after p still works (fork’s built-in action). Fixed (#165). P/gp/gP are now registered by registerCoreVimActions() which is always called regardless of the workspace navigation setting.
  • Dot-repeat: Pressing . after cycling repeats the original paste, not the final cycled text. Fixed. On cycling exit, the final cycled content is written to the original paste register. The fork’s repeatLastEdit re-reads the register at replay time, so . pastes the final cycled text. Follows yanky.nvim’s update_register_on_cycle semantics. System clipboard registers ("+/"*) are excluded.
  • Undo grouping: Each cycle replacement uses addToHistory.of(false) so it does not create a separate undo entry. Pressing u after cycling undoes the entire paste+cycle sequence.
  • Register traversal: Only numbered registers "1–"9 are traversed (delete/change history). Register "0 (last yank) and "- (small delete) are not included in the cycle.

Indentation text object

Status: Implemented. ii/ai select indentation blocks.

ii (inner indentation) selects all contiguous lines with the same or greater indentation level as the cursor line. ai (around indentation) extends the selection to include the parent line above (first line with strictly less indentation) and trailing blank lines below.

Zero-indentation lines and blank lines return no match (no selection change). Tab indentation is handled column-aware using the editor’s tabSize setting.

Gated behind the existing enableTextObjects setting.

Animated cursor (smear + smooth movement)

Status: Implemented (Phase 1 + Phase 2 + Phase 3). (#78)

Canvas-based animated cursor with smooth movement and spring-damper smear trail. Per-mode cursor shape rendering (block, bar, underline, hollow). Fork-side cursor suppression via setCursorSuppressed(). Disabled by default — enable via Settings → Vim Motions → Animated cursor or set smoothcursor / vim.opt.smoothcursor = true.

Known limitations:

  • Fork mode only: The animated cursor requires the bundled codemirror-vim fork. It does not work with Obsidian’s built-in vim mode.
  • Pop-out windows: The rAF scheduler uses the main window’s requestAnimationFrame. Canvases in pop-out windows are not ticked.
  • Cursorline desync: When the cursor animates from one line to another, the cursorline highlight jumps instantly to the destination (driven by selection state). The cursorline does not animate in sync with the smooth cursor.
  • No cursor blink: Fixed. The canvas cursor now blinks matching CM6’s default behavior (1200ms cycle, 600ms reset delay after movement). Blink only when focused.
  • No vim.opt / vimrc configuration: Fixed. All 8 animated cursor settings are available via set smoothcursor / vim.opt.smoothcursor and related options.
  • Textarea and table cell editor fallback: The animated cursor does not render inside textarea vim overlays or native table cell editors. These contexts have the native cursor restored via per-view un-suppression in their constructors. On teardown, clearCursorSuppressedForView() removes the per-view override so the view falls back to the global suppression state.
  • Popover and modal editor fallback: The animated cursor canvas (z-index: 15) renders behind Obsidian’s popovers (z-index: 30) and modals. Editors inside .popover or .modal-container are detected by the CursorController constructor (isAboveCanvas flag) and fall back to the fork’s native vim cursor via setCursorSuppressedForView(view, false). The tick() method skips canvas rendering for these views. This covers footnote popovers, hover editors, and third-party plugin editors created inside modals. (#130)
  • Doubled cursors in embedded editors: Fixed. In textarea vim overlays, the native text caret appeared alongside the fork’s block cursor after switching from insert to normal mode. Root cause: BlockCursorPlugin.update() checked the .cm-vimMode DOM class to determine insert/normal mode, but CM6 ViewPlugin update ordering meant the class may not yet reflect the current mode. Fixed in the fork by checking this.cm.state.vim.insertMode directly. Also uses setProperty("caret-color", ..., "important") for CSS specificity robustness. (#130)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (BlockCursorPlugin.update() — vim-state-based caretColor)
  • Escape does not close footnote popover: Fixed. The fork’s findKey consumed <Esc> unconditionally in idle normal mode. Fixed via setIdleEscapeCallback API — the plugin registers a callback that calls HoverPopover.hide() for non-workspace-leaf editors (popovers, modals) while silently consuming Escape in workspace-leaf editors. (#130)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (setIdleEscapeCallback API)
    • Plugin: src/vim/escape-guard.ts (installEscapeGuard)
  • Stale cursor suppression after animated cursor toggle: Fixed. CursorController constructor now gates suppression on config.enabled; update() clears per-view override when disabled. (#130)
  • Cursor flashing in Normal mode after table interaction: Fixed. Four issues: (1) mainEditorTableCursorGuard and cellEditorCursorGuard in table-cell-cursor-guard.ts 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’s global setCursorSuppressed(true), causing the native CM6 cursor to flash alongside the canvas cursor. (2) mainEditorTableCursorGuard.destroy() did not restore suppression state when the cursor was inside a table at destruction time, leaving a stale true override through plugin recreation. (3) cellEditorCursorGuard.update() force-unsuppressed the cell cursor on every update cycle (same anti-pattern removed from CursorController.update() in commit 62444df). (4) reloadFeatures() did not call setCursorSuppressed(this.settings.animatedCursor) — the global suppression flag was only set during onload(), so any runtime setting toggle via reloadFeatures() left the global flag stale. All unsuppress paths in table-cell-cursor-guard.ts and table-nav-controller.ts now use clearCursorSuppressedForView() (removes the override, falls back to global state). reloadFeatures() now syncs the global suppression flag on every call. Also fixes invisible cursor in textarea vim when animated cursor is enabled. (#127)
  • Doubled cursors when animated cursor is disabled: Fixed. When animated cursor was disabled, the native CM6 cursor (thin blinking bar) appeared alongside the fork’s vim cursor (block/hollow) in normal, operator-pending, and replace modes. The native cursor became visible after entering and leaving insert mode. Root cause: the fork’s BlockCursorPlugin.update() relied on a CSS baseTheme rule (.cm-vimMode > .cm-cursorLayer:not(.cm-vimCursorLayer) { display: none }) to hide native cursor layers, but CM6’s drawSelection extension and mode transitions (insert mode removes .cm-vimMode, normal mode re-adds it) left native layers visible due to CSS specificity conflicts. Fixed in the fork by unconditionally hiding native CM6 cursor layers (display: none) and setting caretColor: transparent on every update() call, regardless of suppression state. In insert mode with bar cursor (.cm-vimMode removed, fork doesn’t render a fat cursor), caretColor is set to var(--interactive-accent) to match the vim cursor color. The suppression API (setCursorSuppressed) now only controls the fork’s own vim cursor layer visibility. (#129)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (BlockCursorPlugin.update() — unconditional native layer hiding + mode-aware caretColor)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (updated setCursorSuppressed API section)
  • Count-prefixed and multi-key motions not animated: Fixed. Movements like 4j and g$ caused the cursor to teleport because resolveVimMode() used vim.status (the chord display string set on every keystroke) to detect operator-pending mode. This triggered false cursor shape changes (block → underline → block), and each shape change called snap(). Fixed by gating operator-pending detection on inputState.operator only. (#86)
  • Table navigation cursor hiding: Both native and animated cursors are hidden during embedded table navigation. The controller dispatches enterTableNav so the mainEditorTableCursorGuard defers to table-nav, and suppressWidgetCursorLayers() hides cell editor cursor layers on every ViewUpdate (cell editors are destroyed and recreated during entry, each spawning a fresh BlockCursorPlugin with a visible cursor layer). cellEditorCursorGuard.destroy() guards on isTableNavActive() to avoid clearing parent cursor suppression during table-nav. Parent cursor suppression stays active throughout the nav→edit transition — enterCellEdit() does not clear suppression; only the exit paths (exitCellEditToNav, exitTable) manage suppression state, preventing stale requestMeasure callbacks from briefly unsuppressing the parent cursor. The animated cursor snaps to the exit position (no interpolation) when resuming after table navigation. (#135, #136)
  • Multi-editor support: The animated cursor now supports multiple editors (including the oil file explorer) via a single shared canvas architecture. MAX_CONTROLLERS is 16.
  • Multi-cursor: Only the primary cursor is animated. Secondary cursors (from visual block or multi-cursor plugins) are not rendered by the animated cursor.
  • Incompatible with cursor animation plugins: ninja-cursor and cursor-smith plugins conflict with the animated cursor. Disable them when using the built-in animated cursor.
  • EoL cursor displacement in visual mode: Fixed. The animated cursor rendered one character past the last visible character at end-of-line during forward visual selection. refreshTarget() used a ch !== '\n' guard that prevented stepping back from sel.head when it pointed to a newline. Replaced with a line-boundary guard (pos > line.from) that correctly handles end-of-line, empty lines, and document end. (#105)
  • Character displaced on lines with tall content: Fixed. On lines containing tall inline content (e.g., MathJax with \dfrac), the character beneath the block cursor could shift vertically. The renderer centered the character within the coordsAtPos() rect height, which on some platforms returns the full line height instead of the per-character height. On a tall line (~80px) with a normal font height (~19px), this caused a ~30px downward shift. Fixed by using the actual DOM character bounding rect (Range.getBoundingClientRect() via domAtPos) for baseline calculation instead of relying solely on coordsAtPos(). Falls back to the coordsAtPos() rect when the DOM rect is unavailable (widget/replaced content). (#106)
  • Bogus coordinates in replaced widgets: coordsToRect() includes a bounds check to reject bogus coordinates from coordsAtPos() when the cursor is inside a replaced widget (e.g., a math block or image). This prevents the cursor from “flying away” to the top-left of the viewport.
  • Canvas context limits: The manager includes a null-check on getContext('2d') to handle browser-imposed limits on the number of active canvases.
  • rAF loop death on Windows: Fixed. The rAF loop is now wrapped in try/catch so a single bad frame cannot kill the animation permanently. A heartbeat setInterval detects stalled loops (via lastLoopTime timestamp comparison) and re-wakes them (covers Windows 11 Efficiency Mode, window occlusion tracking, and high-resolution timer suppression). The heartbeat runs only during the hot gear (active animation) and stops on convergence. A visibilitychange listener re-wakes the loop when the page regains visibility. Canvas backing-store dimensions are rounded with Math.round() for fractional devicePixelRatio on Windows displays with 125%/150% scaling.
  • Excessive GPU usage at idle: Fixed. The rAF loop ran continuously at 60-120fps whenever the editor had focus. A 3-gear frame governor now manages scheduling: hot (rAF ~60fps during active animation, capped at 62.5fps on 120Hz+), warm (setTimeout 600ms for blink toggle, ~1.67 rAF/sec), stopped (no scheduling when unfocused or reduced-motion). Dirty-rect clearing reduces canvas fill from full viewport to cursor-sized region. Per-frame overhead reduced via cached getComputedStyle, matchMedia, accent color, and mutable physics quad. (#148)
  • Cursor does not follow the text while scrolling, and leaves a phantom behind: Fixed. CursorController.update() already compared scrollDOM.scrollTop/scrollLeft against its cached values, but it only runs when CodeMirror produces a ViewUpdate. Scrolling inside the already-rendered viewport produces no transaction, so the cursor stayed pinned to its last screen position until the 500 ms staleness fallback in tick() — and in the warm gear the next tick is up to 600 ms away, so during a continuous scroll it never caught up. Separately, once the caret scrolled outside the pane, coordsToRect() returned null and refreshTarget() returned early without clearing cachedRect, so tick() kept repainting the last known rect (block shape plus its cached character — the reported “phantom letter”). Because a controller that draws always leaves a non-null dirty region, the manager’s full-canvas clear never ran and the phantom was never erased. Fixed with a passive scroll listener on scrollDOM that marks the position dirty and wakes the manager, and by clearing cachedRect/cachedShapeRect when the caret leaves the pane. The pane test is now 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 on every scroll. (#181)
    • Plugin: src/vim/animated-cursor/controller.ts (onScroll listener, refreshTarget() clears the stale rect, coordsToRect() vertical intersection test)
  • The character under a block cursor stayed behind when scrolling: Fixed. resolveBlockChar() caches charTop/charHeight — the viewport coordinates of the character’s DOM rect, added in #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 returned coordinates measured before the scroll and fillText painted the letter where the caret used to be while fillRect drew the block at the new one. The dirty region reported for the frame covers only the block, so the stranded letter was outside every subsequent clearRect. Instrumenting the canvas after a 40 px scroll showed the block moving to y 668.9 while the glyph stayed 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)
  • Settings that gate an editor extension did nothing until restart: Fixed. Only setupVimSubsystems() populated vimExtensionSlot, and it runs from onload() and enableVim() — never from reloadFeatures(), which is what settings toggles call. animatedCursor was the visible case: the toggle suppressed the fork’s cursor via reloadFeatures() and never installed the canvas meant to replace it, so the editor showed no cursor at all until Obsidian restarted. snippetTriggerMode and enableUndoTree were silently restart-only for the same reason, and enableUndoTree additionally never reached a reload path. enableSnippets was the least affected: its extensions also stayed installed, but createSnippetTabKeymap re-reads the setting on every keypress, so the tab trigger already refused to expand — the leak was the completion source and the runtime plugin remaining loaded. setupVimSubsystems() is a one-shot builder and cannot be re-run, and teardownVimSubsystems() is far too destructive for a settings change (it closes pickers, destroys the Lua state and its timers, and detaches Oil leaves). Each gated feature now owns a nested Extension[] pushed into vimExtensionSlot once, whose contents are swapped in place by refreshRuntimeExtensionSlots() followed by a single workspace.updateOptions() — the mechanism the treesitter bridge already used to append its extension after an async load. Built extensions are cached so their identity is stable, which is what lets CodeMirror keep existing ViewPlugin instances alive across an unrelated reload. (#181)
    • Plugin: src/main.ts (animatedCursorSlot, undoTreeSlot, snippetCompletionSlot, snippetTabSlot, snippetRuntimeSlot, setSlotEnabled, populateRuntimeSlots, refreshRuntimeExtensionSlots), src/settings.ts (enableUndoTree added to RELOAD_KEYS and to the imperative handler)
  • Cursor shape changes did not reach the animated cursor until restart: Fixed. cursorShapes has two independent consumers. The fork reads state.vim.cursorShapes live on every render and already tracked settings changes at runtime — that path was never broken. The animated cursor does not: setCursorShapes() copies the map into its own module state, and only setupVimSubsystems() called it, so with the animated cursor enabled a shape change did nothing until Obsidian restarted. Unlike the gated settings above, a slot cannot fix this — the bundled vim extension is never gated, so there is no membership to change; the reload path has to re-push the value instead. (#181)
    • Plugin: src/main.ts (reloadFeatures() re-applies setCursorShapes)
  • spike-mutable-array-destroy failed on Windows CI: Resolved, and it was never a regression — it failed identically on f1b5626, before the #181 work. After clearing a mutable extension array and calling workspace.updateOptions(), the spike’s ViewPlugin was destroyed but its keydown observer still fired, on Windows only. Two rounds of diagnostics answered it: the observer fired from embedded:table-widget — a native table cell editor — which still had the plugin configured while the active leaf editor had already dropped it. Windows CI happened to have a table cell editor open where Linux did not. This is by design, not a defect. Embedded editors (table cells, popovers, textarea overlays) receive their extensions when they are constructed and are not workspace-leaf editors, so workspace.updateOptions() never reconfigures them; the spike was asserting a guarantee that registerEditorExtension does not make for that class. It now counts only editors that mechanism governs, and focuses the leaf editor explicitly rather than clicking the first .cm-editor in the document, which on Windows could be an embedded one. CodeMirror was exonerated along the way: in 6.38.6 ensureHandlers() recomputes the handler map whenever the plugin set changes, and it matched the configuration in both views.
  • Disabling vim does not reconfigure an already-open embedded editor: A consequence of the above, recorded because it is real rather than because it is known to bite. disableVim() clears the vim extension slot and calls workspace.updateOptions(), which reaches workspace-leaf editors. An embedded editor that is already open — a table cell being edited, a popover, a textarea overlay — keeps the extensions it was constructed with, so vim stays active inside it until it is destroyed. Embedded editors are short-lived, and one created after the toggle gets the current configuration, so the window is small. test/specs/vim-toggle.e2e.ts asserts the observer count only across reconfigurable editors for this reason.
  • The cursor could stay missing after scrolling back to the caret: Fixed. Caught by CI on Windows, where the cursor never came back at all; on Linux it returned after ~650 ms, which the original assertion accepted. Three faults stacked. wake() was not sticky — one arriving while a frame was in flight returned early on running, and that frame then parked the loop, discarding it. The blink’s dark half is 600 ms and the warm gear also ticks every 600 ms, so a parked loop can land on the dark half of every blink and draw nothing indefinitely; a scroll now counts as movement for blink purposes and shows the cursor solid, as it does in Neovim. And the scroll listener deduplicated against cachedScrollTop, which stops advancing while the caret is off-pane, so scrolling back to exactly the last resolved offset looked like no change and skipped the wake entirely. (#181)
    • Plugin: src/vim/animated-cursor/manager.ts (wakeRequested), src/vim/animated-cursor/controller.ts (lastSeenScrollTop/Left, positionRetryUntil, blink reset on scroll)
  • Teardown resurrected the animated-cursor manager: Fixed. teardownVimSubsystems() destroys the manager, but CodeMirror only destroys the controllers on the later workspace.updateOptions(), so CursorController.destroy() ran after the manager was gone and called getAnimatedCursorManager() — building a replacement purely to deregister from it, which nothing then tore down. The replacement had no canvas, rAF loop or listeners (ensureCanvas() only runs from register()), so the effect was a leaked object rather than a visible fault. destroy() now uses peekAnimatedCursorManager(), which never creates. Fixing it this way rather than by reordering teardown keeps it correct regardless of the order the two happen in. (#181)
    • Plugin: src/vim/animated-cursor/manager.ts (peekAnimatedCursorManager), src/vim/animated-cursor/controller.ts (destroy())

Nice-to-have (future iterations):

  • Insert mode trail suppression: Shorter or disabled smear trail in insert mode to avoid distracting trails during typing (matching smear-cursor.nvim’s max_length_insert_mode: 1).
  • Operator-pending mode detection: Fixed. Operator-pending mode (d, c, y waiting for motion) is now detected via vim.inputState.operator with per-frame shape polling.
  • vim.opt.smoothcursor / vimrc set smoothcursor: Fixed. All 8 options available via vimrc (set smoothcursor, set smoothcursorsmoothness=0.3, etc.) and Lua (vim.opt.smoothcursor = true, etc.).
  • Oil explorer support: Fixed. Animated cursor renders in oil explorer via shared single-canvas architecture. Table cell editors and textarea overlays fall back to native cursor.
  • Multi-cursor animation: Animate secondary cursors (static indicators or spring physics). Deferred to Phase 4+ — multi-cursor in vim is uncommon.
  • Built-in vim mode support: Position source validation and cursor hiding for Obsidian’s built-in vim mode (Phase 4).
  • Pop-out window support: Per-window rAF scheduling for pop-out windows (Phase 4).

Settings: leader bindings and which-key labels use imperative rendering

Status: Known limitation. Architecturally incompatible with SettingDefinitionList.

The leader key bindings, which-key group labels, and which-key command labels groups use render callbacks in the declarative settings path (getSettingDefinitions()) that delegate to imperative methods (renderLeaderBindings, renderGroupLabels, renderCommandLabels). These render methods build the UI manually with new Setting() calls, add/remove buttons, and dynamic row management.

Investigation found that Obsidian 1.13+‘s SettingDefinitionList (type: 'list') is designed for lists of single-control items, not multi-column table rows. Each leader binding row has 2 text inputs + 2 buttons; each group/command label row has 4 text inputs + 1 button. SettingDefinitionList items are SettingDefinitionItem[] with one control per item — there is no built-in support for multi-input rows. Converting would require either losing the multi-column layout (poor UX) or using render callbacks inside the list (losing the native drag/delete/add affordances that motivated the migration).

The current render callback approach is the correct pattern for these complex settings. The imperative path works identically on pre-1.13. No migration planned.

Resolved Issues

Vim keymaps intermittently stop working (Fixed)

Status: Fixed. Multi-layered defense implemented across fork and plugin. (#18)

gg, G, and other keymaps could intermittently stop working until Obsidian was reloaded. The issue had multiple contributing root causes in the codemirror-vim fork’s state management:

  1. Stale normal-mode key prefix: Typing g buffers it in inputState.keyBuffer as a partial match. If the editor lost focus (tab switch, modal open, window blur) before the second key, the prefix persisted indefinitely — no timeout exists for normal-mode partials (unlike insert mode’s lastInsertModeKeyTimer), and no blur/focus handler existed. On refocus, the next key combined with the stale g to produce an invalid sequence (gG, gj, etc.), which was silently swallowed. Fix: blur handler on contentDOM calls clearInputState() on focus loss; pane-switch handler in the plugin provides belt-and-suspenders coverage.

  2. Global keymap corruption via unmap(): The fork’s defaultKeymap is a global singleton shared across all editors. unmap() used splice() to remove entries, including built-in defaults like gg or j. During plugin lifecycle churn (enable/disable/reload), unregisterAll() called unmap() on registered keys, which could accidentally remove defaults. Once removed, the key was permanently gone until page reload — mapclear() only removed user mappings, and there was no resetKeymap(). Fix: defaults tagged with _isDefault, unmap() skips them, resetKeymap() restores from frozen snapshot, mapclear() uses flag-based partitioning.

  3. Incomplete leaveVimMode() cleanup: When an editor was destroyed while in insert mode, the change and keydown listeners registered by enterInsertMode() were not removed (only exitInsertMode() removes them, and leaveVimMode() didn’t call it). The global lastInsertModeKeyTimer could also fire against a destroyed editor. Fix: leaveVimMode() now manually removes insert-mode listeners, clears the timer, clears virtualPrompt, and resets inputState.

  4. Async motion race conditions: Async motion callbacks (used by EasyMotion operator-pending mode) had no way to detect if a newer command had superseded them. A d + async motion that resolved after the user typed another key could apply the delete at the wrong position. Fix: _commandGeneration counter on vim state, captured before dispatch and validated in the .then() callback.

Test coverage: 10 fork unit tests + 7 plugin e2e tests covering blur recovery, plugin reload, keymap protection, resetKeymap() recovery, and leaveVimMode cleanup.

  1. Stale jumpList markers after document switch: The global jumpList (vimGlobalState.jumpList) stores Marker objects with absolute document offsets. When switching between documents of different lengths (especially via PDF++ or other non-editor views), markers from the old (longer) document held offsets exceeding the new document’s length. jumpList.add() called curMark.find() → posFromIndex(this.offset) → doc.lineAt(offset) with no bounds check, throwing RangeError. The exception bubbled through processMotion → processCommand → the cm.operation() try-catch, which wiped vim state (cm.state.vim = undefined; maybeInitVimState(cm)) and re-threw. The re-initialized state lost per-instance configuration. Fix (three layers):
    • posFromIndex clamps offset to [0, doc.length] before calling doc.lineAt(), mirroring the bounds checking already present in indexFromPos
    • Marker.find() catches exceptions and returns null — all callers (jumpList.add, jumpList.move, jumpList.find) already handle null gracefully
    • Marker.update() catches RangeError from mapPos() when the marker offset exceeds the changeset’s starting document length, setting offset = null
    • Plugin: reloadFeatures() now calls vim.resetKeymap() to match onload() behavior, closing a defense gap where settings-triggered reloads could corrupt the keymap without recovery

Additional test coverage: 5 fork tests (posFromIndex clamping, negative offset, valid offset, marker doc-shrink, gg/G with stale jumpList) + 3 plugin e2e tests (gg/G after document switch, gg/G after reloadFeatures on shorter document).

Custom text objects via Lua (vim.textobject) (Fixed)

Status: Fixed. Lua text object specs are now persisted and re-registered after reloadFeatures().

The vim.textobject.add() / vim.gen_spec.pair() API registers custom text objects from Lua configuration. The pair matching logic (asymmetric delimiters, nesting, multi-line) is verified working via 26 unit tests on createAsymmetricPairTextObject and 5 passing E2E tests.

Root cause of the original issue: loadLuaConfigInternal() called reloadFeatures() AFTER the Lua config registered text objects. reloadFeatures() destroyed the VimRegistration instance that held the Lua-registered keybindings and created a fresh one that had no knowledge of them. Fix: text object specs are stored in this.luaTextObjectSpecs[] during the onTextObjectAdd callback, then reregisterLuaTextObjects() replays them on the new VimRegistration instance after reloadFeatures() completes.

Block cursor displays wrong character after editor refocus (Fixed)

Status: Fixed in the codemirror-vim fork. (#71)

When the editor lost and regained focus (e.g., opening/closing DevTools, switching windows), the vim block cursor could display the wrong character. This occurred because Obsidian’s Live Preview re-expands hidden markdown formatting (like ## in headings) when the editor regains focus, but the block cursor’s requestMeasure ran in the same animation frame — before the browser reflowed the newly expanded decorations. coordsAtPos() returned stale layout coordinates from the pre-reflow DOM, causing the cursor to render the character from the old visual position.

Fixed with two changes in the fork:

  1. BlockCursorPlugin.update() now includes update.focusChanged in its redraw trigger, ensuring the cursor re-measures on focus transitions.
  2. On focus gain, a deferred requestAnimationFrame schedules a second requestMeasure that runs after the browser has reflowed the decoration DOM changes.

Smart list continuation and frontmatter (Fixed)

O (open line above) on the first content line after YAML frontmatter previously behaved like o (open line below). The smart list continuation override in src/actions/open-line.ts compared curLine === cm.firstLine() to decide whether to use the “insert at document start” path. With frontmatter present, cm.firstLine() returns 0 (the opening ---) while the cursor is on the first post-frontmatter line (e.g. line 3), so the check was always false. The else branch inserted at the end of the previous line — which fell inside the frontmatter region, causing Obsidian’s properties UI to swallow the new line.

Fixed in both layers:

  • Fork (vim.js): newLineAndEnterInsertMode now scans past ----delimited frontmatter to find the first editable line and uses insertAt.line <= firstEditable as the boundary check. The insertion point uses { line: insertAt.line, ch: 0 } instead of hardcoded cm.firstLine(). This fixes O on all line types (plain text, headings, etc.).
  • Plugin (open-line.ts): the smart list continuation override adds firstEditableLine() with the same frontmatter scan, changing the boundary check to curLine <= firstEditableLine(cm). This fixes O on list lines specifically.

Documents without frontmatter are unaffected — both paths fall back to cm.firstLine() when the first line is not ---.

Test coverage: test/specs/open-line-list.e2e.ts — 7 regression tests: O on unordered/ordered/task list after frontmatter inserts above, o on list after frontmatter inserts below, O on non-list line after frontmatter inserts above, o on non-list line after frontmatter inserts below, O on second line after frontmatter uses normal insertion path.

Scrolloff line height assumption (Fixed)

Scrolloff now uses EditorView.defaultLineHeight to dynamically measure the actual line height instead of assuming 22px. The margin adapts automatically when the user changes font size or line height. Note: defaultLineHeight returns an average line height — documents with mixed-height lines (e.g., headings with larger fonts) may not have pixel-perfect scrolloff distances.

The scrolloff value accepts 0–9999 (previously capped at 20). Setting set scrolloff=999 in your vimrc keeps the cursor vertically centered while scrolling, matching standard Vim behavior. The Settings UI uses a validated number input field instead of a slider. The scroll margin is clamped to half the viewport height at runtime, mirroring Vim’s silent cap of scrolloff to (window_height - 1) / 2. (#40, #48)

Absolute line number highlight not updating on cursor movement (Fixed)

Status: Fixed. lineMarkerChange now includes update.selectionSet in absolute mode. (#68)

When only absolute line numbers were enabled (set number without set relativenumber), the vim-motions-line-num-current CSS class (bold highlight on the current line number) did not update when the cursor moved. The highlight stayed stuck on whichever line was current when the document was last modified, only updating incidentally when entering special content (MathJax, images) that triggered docChanged or viewportChanged.

Root cause: the lineMarkerChange callback in the CM6 gutter() configuration — in both the standalone line number gutter (src/vim/line-number-gutter.ts) and the unified statuscolumn gutter (src/vim/statuscolumn.ts) — returned update.docChanged (without update.selectionSet) when the mode was absolute-only. This was an optimization assuming the displayed text doesn’t change on cursor movement (true for absolute numbers), but it forgot that the isCurrent flag on each LineNumberMarker also changes — without a re-render, the CSS class was never added/removed. Relative and hybrid modes were unaffected because they already included update.selectionSet (the displayed numbers change on every cursor move).

Fold gutter click does not unfold (Fixed)

Status: Fixed. A transaction extender normalizes unfoldEffect ranges for all fold sources. (#80)

Clicking a fold marker to unfold had no effect. CM6’s foldState requires an exact {from, to} match to remove a fold decoration; a mismatched range is silently ignored.

Two layers were fixed: (1) The plugin’s own custom gutters (fold-column.ts, statuscolumn.ts) dispatched unfoldEffect with { from: line.from, to: line.from } (zero-width range) instead of the actual fold range. Fixed by capturing the fold end from foldedRanges().between(). (2) The broader issue: these custom gutters are off by default, and Obsidian’s native fold gutter can also dispatch unfold effects with ranges that don’t exactly match the stored fold. Fixed by adding unfoldNormalizerExtender in src/vim/fold-sync.ts — a transactionExtender that detects mismatched unfold effects and appends a corrective effect with the actual stored fold range. This works for all fold sources: Obsidian’s native gutter, the plugin’s custom gutters, and vim commands.

Properties fold observer causes scroll jump with third-party plugins (Fixed)

Status: Fixed. The observer now only reacts to is-collapsed class toggles. (#89)

The propertiesFoldObserver ViewPlugin in src/vim/fold-sync.ts watches .metadata-container for class mutations and dispatches EditorView.scrollIntoView() to keep the cursor visible after properties fold/unfold. The observer originally reacted to ANY class attribute change — including no-op re-assignments and non-fold mutations from third-party plugins (e.g., Meta Bind input fields in the properties panel). This caused the editor to scroll back to the last vim cursor position whenever a plugin triggered a class mutation on the metadata container.

Fixed by adding attributeOldValue: true to the MutationObserver config and comparing old vs new is-collapsed presence. The observer now only fires when the fold state actually changes. No-op mutations (identical class string before and after) and non-fold mutations (any class other than is-collapsed) are ignored.

Test coverage: test/specs/properties-fold-scroll.e2e.ts — 4 regression tests: non-fold class mutation preserves scroll, no-op class re-assignment preserves scroll, fold toggle triggers scroll, unfold toggle triggers scroll.

Insert mode escape (set insertmodeescape=jk) not working (Fixed)

Status: Fixed. InsertEscapeHandler rewritten to use DOM keydown events; timeout made configurable. (#31)

set insertmodeescape=jk required frame-perfect input timing (effectively unusable). Two issues were identified:

  1. Wrong event source: The handler listened to vim-keypress events on the codemirror-vim adapter. In insert mode, regular character keys (j, k) bypass the vim command pipeline entirely and go through CM6’s text input handler — vim-keypress only fires for keys that codemirror-vim processes as vim commands (e.g., <Esc>, mapped sequences). The handler never saw insert-mode character keystrokes.

  2. Option value not retrievable: The insertmodeescape option’s defineOption callback did not store the value for getOption() retrieval. When getOption('insertmodeescape') was called, it returned undefined (the callback returned nothing on query), so the handler’s escape sequence check always short-circuited at escapeSeq.length < 2.

Fix: Rewrote InsertEscapeHandler (src/vim/insert-escape.ts) to use DOM keydown events captured on the editor element. The handler filters for single printable characters (ignoring Ctrl/Alt/Meta modifiers), checks the vim state for insert mode via the adapter, and accumulates a sequence buffer with configurable timeout. On match, e.preventDefault() + e.stopPropagation() blocks the final character from being inserted, then <BS> × sequence length + <Esc> is dispatched through the vim API. Added module-level storage for both insertmodeescape and insertmodeescapetimeout option values so getOption() returns the configured values.

Timeout: Configurable via set insertmodeescapetimeout=N (alias imet, range 100–5000ms, default 1000ms — matching Neovim’s timeoutlen). Previously hardcoded at 200ms. Also configurable via Settings → Vim Motions → Vim engine → Insert mode escape timeout.

Test coverage: test/specs/vimrc.e2e.ts — two tests: jk typed within timeout exits insert mode, jk typed after timeout stays in insert mode.

EasyMotion leader key conflict with mapCommand (Fixed)

EasyMotion and hint mode bindings call unmapDefaultBinding(leader) before mapCommand registration. This removes the leader key’s default Vim binding (e.g. <Space> → l, , → repeatLastCharacterSearch) from codemirror-vim’s keymap so that mapCommand multi-key sequences starting with the leader can accumulate in the input buffer. The vimrc parser correctly handles let mapleader = " " (space inside quotes). EasyMotion works with any leader key, including space, comma, and semicolon.

unmapDefaultBinding passes { includeDefaults: true } to vim.unmap(), which is required because codemirror-vim’s default keymap entries are tagged with _isDefault and unmap() silently skips them without this flag. Without includeDefaults, keys with built-in bindings (,, ;, -, +, etc.) would not be unmapped, causing the default single-key binding to consume the first keystroke before the multi-key EasyMotion sequence (e.g. ,,w) could accumulate.

The plugin now unmaps the leader key’s default binding centrally — after vimrc loading, in reregisterLeaderFeatures(), and in reloadFeatures() — independent of which features are enabled. Previously, unmapDefaultBinding(leader) was only called inside registerEasyMotion(), so keys with default bindings (most notably space, whose <Space> → l default caused it to move the cursor right instead of acting as leader) only worked as leader when EasyMotion was enabled. All leader-dependent features (table manipulation, hint mode, settings leader bindings) now work with any leader key even when EasyMotion is disabled. (#21)

The fork also normalizes literal special characters in key strings to angle-bracket notation when they enter the keymap. The <leader> substitution in the vimrc loader replaces <leader> with the literal leader character — for space, this produces ' j' from nmap <leader>j gj. However, vimKeyFromEvent converts space key presses to '<Space>' (angle-bracket notation). Without normalization, commandMatch('<Space>', ' j') would never match because it uses exact string comparison. The fork’s normalizeKeyString converts ' j' to '<Space>j' in _mapCommand before the entry is stored, so the dispatched '<Space>' correctly partial-matches and '<Space>j' fully matches. This normalization also applies to toKeys (the rhs of keyToKey mappings), unmap(), and removeMapCommand().

When .obsidian.vimrc sets a custom leader via let mapleader = ",", the plugin properly cleans up the initial backslash-leader bindings and re-registers all leader-dependent features (EasyMotion, hint mode, table manipulation, settings leader bindings) with the new leader. Previously, the old \-leader mapCommand entries persisted in the keymap alongside the new leader bindings because Vim.unmap() could not remove mapCommand-created entries. The fork provides Vim.removeMapCommand(keys) for clean removal. Additionally, the fork’s unmap(lhs, ctx) now supports per-mode removal of context-less (all-mode) mappings — when a mode-specific unmap finds no exact context match, it splits the context-less entry into per-mode entries for the remaining modes, matching Neovim’s :nunmap on a :map-created mapping.

Test coverage: test/specs/vimrc.e2e.ts now reads the resolved leader from LeaderRegistry and counts the real EasyMotion label overlay for both space and comma. The earlier red result was a test bug: its helper wrote .obsidian.vimrc before reloadObsidian({ vault: 'test-vault' }), which replaced the launched vault and discarded the file. The supposedly loaded run therefore had no vimrc (commandCount: 0, watchPath: null, file absent) and correctly retained the default \ leader. The corrected helper reloads first, writes the file, and invokes the configuration reload path.

Visual mode on single-character text objects (Fixed)

Status: Fixed. The formatting mark transaction filter that caused cursor snapping has been removed.

vi* on *x* previously selected * (the delimiter) instead of x (the content). The root cause was believed to be Live Preview cursor snapping from Obsidian’s Decoration.replace({}) hiding formatting marks. An EditorState.transactionFilter was introduced to compensate by snapping cursor positions away from formatting mark ranges.

Investigation (issue #33) found that the transaction filter was the sole cause of cursor snapping for double-character marks (**, __, ~~, ==). Empirical testing confirmed:

  • On the active line, Obsidian uses Decoration.mark (not Decoration.replace) — formatting marks are real text nodes with full width in the DOM
  • With the filter disabled, h/l movement through **hi** visits every position without skipping
  • Mark visibility in Live Preview is controlled entirely by Obsidian based on cursor proximity, unaffected by the filter
  • vi*, di*, da* and other text objects work correctly without the filter

The transaction filter, the formattingMarkMode setting, and the formattingmarkmode vim option have been removed.

Not to be confused with src/snippets/live-preview-guard.ts, added for #198. The filter removed here was itself moving the cursor away from formatting-mark ranges on every transaction. The snippet guard does the opposite and is far narrower: it drops Obsidian’s own corrective selection dispatch, and only the one that arrives immediately after a snippet tabstop jump. Obsidian’s snap is real — it is what pushed a tabstop out of *a* — but it is dispatched by Obsidian, from a zero-delay timer, against the decoration set built for the previous selection, and it skips any transaction that also changed the document. That is why it reaches tabstop jumps and not ordinary cursor motion, and why removing the guard is not a repeat of the fix recorded above.

Permanent limitation: ci* in Live Preview — Investigation (spike27) found that ci* works correctly in Live Preview for multi-character content (**bold text** → ci* → type replacement → correct result). On the active line, Obsidian uses Decoration.mark (visible text nodes), not Decoration.replace — the cursor is not displaced by collapsed decorations. The original limitation was overstated based on early testing with a transaction filter that has since been removed.

Test coverage: test/specs/text-objects.e2e.ts — ci* unskipped and passing for multi-character bold content.

Visual line selection overlap in Live Preview (Fixed)

Status: Fixed. Double-highlight eliminated, cursor displacement resolved. (#41)

Two issues affected visual-line mode (V) in Live Preview:

  1. Double highlight: The plugin’s custom linewiseVisualHighlight decoration (full-line highlight via Decoration.line) and the native CM6 ::selection CSS rendered simultaneously. The native ::selection was hidden in normal mode via .cm-vimMode:not(.cm-vimVisual) but was intentionally left visible in all visual modes (needed for charwise and blockwise). Fixed by adding a .cm-vimVisualLine class toggle and extending the ::selection suppression to include visual-line mode. Charwise and blockwise visual modes remain unaffected.

  2. Cursor displacement over collapsed markup: Navigating with j/k on lines containing collapsed markup ([[wikilinks]], [text](url)) caused Obsidian to uncollapse the hidden content, reflowing the line. This happened because updateCmSelection set a spanning CM6 EditorSelection range across the full line content, and Obsidian’s Live Preview detects selection overlap with Decoration.replace ranges and reveals them (this is Obsidian plugin-level behavior, not CM6 core). Fixed by setting a cursor-only CM6 selection in visual-line mode — the linewiseVisualHighlight ViewPlugin provides the visual highlight independently from vim.sel, and operators recompute their own selection at dispatch time.

Actions that read from the CM6 selection in visual mode (joinLines, replace, continuePaste) were updated to read from vim.sel instead, and a Ctrl+C special-case copies linewise text from vim.sel when somethingSelected() returns false. The continuePaste fix (#139) derives selectionStart/selectionEnd from vim.sel when vim.visualLine is true, and adjusts the linewise text preparation to keep the trailing newline (since the replacement range already spans whole lines). The plugin’s pasteFromRegister() (P/gp/gP) also gained a pasteInVisualMode() handler that reads vim.sel directly. The async motion .then() callback (used by EasyMotion in visual mode) now wraps updateCmSelection in cm.operation() with isVimOp = true to prevent handleExternalSelection from exiting visual mode when it sees cursor-only selection. The cursor-only selection always uses column 0 (matching Neovim) to avoid landing inside widget decorations (checkboxes, collapsed links) on the head line.

Obsidian command passthrough (two layers):

  1. Fork-side (keyboard events): When a key is NOT handled by vim in visual-line mode, handleKey in the fork’s index.ts temporarily expands the CM6 selection to the full linewise range before the event propagates. The cursor-only selection is restored via microtask after Obsidian processes the command. This covers commands triggered by keys that pass through CM6’s bubble-phase event handler.

  2. Plugin-side (all invocation paths): src/vim/visual-line-command-fix.ts uses three complementary patches. First, app.commands.executeCommand is wrapped via around() — when the active editor is in visual-line mode, the wrapper expands the CM6 selection before the command executes and restores cursor-only after. This covers Obsidian hotkeys (capture phase on window), toolbar buttons, and programmatic executeCommandById calls (which delegates to executeCommand internally). Second, every command’s checkCallback is individually wrapped — but only the executing path (checkCallback(false)) expands the selection; the checking path (checkCallback(true)) is passed through without selection expansion because the VisualLineSomethingSelectedPatch (layer 3) already ensures somethingSelected(), getCursor(), and listSelections() return the correct visual-line range. Skipping expansion for the checking path eliminates ~400 CM6 dispatches when the command palette opens with 200+ commands. (#137, #163)

  3. Plugin-side (editor API patching): The VisualLineSomethingSelectedPatch CM6 ViewPlugin patches five methods on Obsidian’s Editor object: somethingSelected() returns true when vim is in visual-line mode with an active selection (or a cached/pending selection exists); getSelection() returns the full linewise text from vim’s selection state and snapshots the selection range; replaceSelection() dispatches a CM6 replacement transaction covering the linewise range (with trailing newline handling) and exits visual-line mode via Vim.handleKey(cm, '<Esc>'); getCursor('from'/'to') returns the visual-line range boundaries instead of the collapsed cursor position; and listSelections() returns the visual-line range as a proper anchor/head selection. The cursor and selection patches are essential because Note Composer uses getCursor('from')/getCursor('to') (not replaceSelection()) to determine the range to replace — without these patches, Note Composer inserts the link at a zero-width cursor position without removing the original text. A pendingVisualLineSel WeakMap (per EditorView, 30 s TTL) snapshots the visual-line range before withExpandedSelection expands the CM6 selection, surviving async modal flows where handleExternalSelection exits visual mode after restoreCursorOnly collapses the selection. The fallback chain is: active vim visual-line state → lastVisualLineSel (set by getSelection()) → pendingVisualLineSel (set by withExpandedSelection). (#138, #157)

  4. Plugin-side (ex command path): The :obcommand (and :ob) ex command handler in src/workspace/commands.ts restores the CM6 selection from the visual range before calling executeCommandById(). The fork’s _processCommand() exits visual mode before invoking ex command handlers, but parseInput_() captures the visual range into params.selectionLine/params.selectionLineEnd beforehand. For the exmap indirection path (where a user-defined ex command chains to :obcommand via vim.handleEx), the inner _processCommand no longer has visual mode context, so the handler falls back to the '</'> vim marks (which persist after exitVisualMode). This covers the common vimrc pattern exmap togglebullets obcommand editor:toggle-bullet-list + vmap <leader>b :togglebullets. (#161)

    That line-only restore was not enough for a charwise selection. A keyToKey mapping such as vim.keymap.set("v", "<C-n>", ":obcommand <id><CR>") feeds : to the prompt, which the fork prefills with '<,'>, so the dispatcher receives '<,'>obcommand <id> — measured selectionLine === selectionLineEnd === 0 for a selection inside one line, which the selectionLine !== selectionLineEnd guard rejected, and the '</'> fallback rejected too because both marks share a line. The selection was dropped entirely, and a charwise selection spanning two lines was widened to both whole lines. The handler now rebuilds the range in document offsets from '</'> and lastSelection.visualLine/visualBlock, which survive exitVisualMode and carry columns. A typed numeric or % range still expands linewise; '<,'> and a bare :obcommand do not. (#192)

Trade-off: cm.somethingSelected() and cm.getSelection() (the CM5-compat adapter methods) return false/empty in visual-line mode during vim key processing. Third-party plugins that depend on CM6 selection state during visual-line mode may not detect the selection. The canonical integration point window.CodeMirrorAdapter.Vim is unaffected. Obsidian’s Editor API (editor.somethingSelected(), editor.getSelection(), editor.replaceSelection(), editor.getCursor(), editor.listSelections()) sees the correct linewise selection because of the VisualLineSomethingSelectedPatch ViewPlugin.

Test coverage: 8 Neovim golden comparison cases + 7 e2e functional tests covering yank, delete, join, mode transitions, gv, register content verification, and mid-column visual-line with checkbox content. 6 e2e tests (visual-line-command.e2e.ts) verifying checkCallback returns true for Note Composer’s split-file command, editor.somethingSelected() returns true, executeCommandById affects all selected lines in visual-line mode, replaceSelection works after visual-line mode is exited between getSelection() and replaceSelection(), real command palette toggle numbered list in V-LINE, and real command palette Note Composer end-to-end extract in V-LINE (opens palette, selects “Extract current selection”, enters filename, verifies text removed and link inserted) (#157). 3 e2e tests (obcommand-visual-mode.e2e.ts) verifying :obcommand toggle-bullet-list and toggle-numbered-list in visual-line mode via direct handleEx and defineEx exmap indirection (#161), plus 4 tests in the same file covering the charwise restore — a real <C-n> mapping read from inside the dispatched command, single-line and two-line charwise, a straddling editor:toggle-bold, and an explicit :1,2obcommand line range (#192). 5 spike tests (spike-issue138-vline-async-replaceSelection.e2e.ts) verifying replaceSelection works in visual-line mode for sync, async, and direct invocation patterns. 10 spike tests (spike23-visual-line-hotkey-commands.e2e.ts) verifying command execution via executeCommandById, hotkey path, and selection state inspection. 7 visual paste tests + 4 Neovim golden cases in normal-yank-put.e2e.ts (#139) verifying v + p, V + p, V + P, v + P, v + gp, unnamed register update, and mode return.

Visual-line mode highlight missing on replaced widget blocks (Fixed)

Status: Fixed. Replaced widget blocks (MathJax, embeds, etc.) now receive visual-line highlight. (#57)

In visual-line mode (V), the fork’s linewiseVisualHighlight ViewPlugin uses Decoration.line() to apply .cm-vim-linewise-selection to each .cm-line element. When Obsidian’s Live Preview replaces content with rendered widgets (block MathJax $$, note embeds ![[note]], plugin table widgets), the .cm-line elements are removed from the DOM and replaced by widget container elements. Decoration.line() silently drops decorations for lines inside replaced ranges, leaving those widget blocks visually unhighlighted during selection.

Fixed by adding a plugin-side LinewiseWidgetHighlight ViewPlugin (src/vim/linewise-widget-highlight.ts) that supplements the fork’s line-level highlighting. On each CM6 update during visual-line mode, the plugin scans contentDOM direct children for non-.cm-line elements (widget containers), maps them to document positions via view.posAtDOM(), and toggles cm-vim-linewise-widget-selection on widgets whose document range overlaps the visual-line selection. The class is removed on mode exit and destroy().

The fix is generic — it highlights any replaced widget type based on DOM structure (non-.cm-line direct child of contentDOM with non-zero height), not specific widget classes. Decoration.mark() was validated as non-viable (marks only wrap text content nodes, which replaced widgets lack). The fork’s linewiseVisualHighlight remains unchanged.

Callout linewise highlight not visible: Fixed. Two CSS specificity issues prevented visual-line selection highlighting from appearing on callouts: (1) Collapsed callout widgets (cm-embed-block cm-callout) had their cm-vim-linewise-widget-selection background overridden by the callout’s own styling. (2) Unfolded callout lines (.cm-line.HyperMD-quote) had their cm-vim-linewise-selection background overridden by Obsidian’s .markdown-source-view.mod-cm6.is-live-preview .HyperMD-quote { background-color: var(--blockquote-background-color) } rule (specificity 0,4,0). Fixed by increasing the selection rule specificity to (0,5,0) via the .cm-editor .cm-scroller .cm-content ancestor chain, outranking Obsidian’s blockquote rule without !important. Both collapsed and unfolded callout states now show the selection highlight. (#103)

Test coverage: spike24 (spike24-visual-line-widget-highlight.e2e.ts) — 12 tests covering MathJax, embed, and code block DOM structure discovery; visual-line highlight verification; decoration facet analysis; posAtDOM() reliability on MathJax and embed widgets; Decoration.mark() validation; and update() trigger verification during cursor-only selection.

Visual mode cursor displaced at end-of-line (Fixed)

Status: Fixed in fork. Verified against Neovim 0.12.2 golden comparison.

In charwise visual mode (v), selecting the last character on a line caused the block cursor to render one character past the end of the visible line content. Two issues were identified and fixed:

  1. exitVisualMode cursor clipping (src/vim.js): exitVisualMode() called clipCursorToContent() while vim.visualMode was still true. In visual mode, clipCursorToContent allows ch = text.length (the linebreak position). After clearing vim.visualMode on the next line, the cursor was already set one position past the last character. Reproducible as: vlll<Esc> on “abc” — l past the last char is allowed in visual mode, but Escape should clip back to normal-mode bounds (ch = text.length - 1). Fixed by clearing visual flags before setCursor, while preserving the updateLastSelection call order. (#15)

  2. measureCursor EOL adjustment (src/block-cursor.ts): The letter != "\n" comparison used loose equality (!=). When head >= doc.length (cursor past document end), the short-circuit head < doc.length && sliceDoc(...) produced false, and false != "\n" evaluated to false due to JS type coercion (both coerce to 0). This caused the wrong branch to execute at document end. Fixed by producing "" instead of false and using strict inequality (!==).

  3. measureCursor visual-block EOL step-back (src/block-cursor.ts): After the makeCmSelection per-line clamping fix (issue #38), block selection heads legitimately land on newline positions (head = lineLen). The else if (!vim.visualLine && !vim.visualBlock) guard prevented the head-- step-back in visual-block mode, causing the cursor to render one position past the last visible character. Fixed by removing && !vim.visualBlock — visual-block now applies the same EOL step-back as charwise visual. The !vim.visualLine guard remains because visual-line mode manages cursor positioning independently via cursor-only CM6 selection. (#41)

Visual-block A skips short lines (Fixed)

Status: Fixed in fork. Verified against Neovim 0.12.2 golden comparison (upstream-gaps suite).

When using <C-v> block visual mode with A (append) on a block spanning lines shorter than the block column, the fork’s selectForInsert skipped those lines entirely. Neovim pads short lines with spaces to reach the block’s right edge before appending. Fixed by adding a padShortLines parameter to selectForInsert — the A (endOfSelectedArea) path passes true to pad, while the I (startOfSelectedArea) path passes false to skip (matching Neovim, which also skips short lines for I). (#41)

Visual charwise r off-by-one across line boundary (Fixed)

Status: Fixed in fork. Verified against Neovim 0.12.2 golden comparison (upstream-gaps suite).

The replace action in the fork set curEnd = selEnd for charwise visual mode. Since cm.getRange(from, to) treats to as exclusive, this replaced one fewer character than the visual selection covered when the selection spanned a newline. For example, vjhr from position (0,4) on wuuuet\nanother replaced 5 characters instead of 6, producing wuuu \n ther instead of the correct wuuu \n her. Fixed by using new Pos(selEnd.line, selEnd.ch + 1) for curEnd, matching the inclusive-to-exclusive conversion used elsewhere (e.g. makeCmSelection char mode). (#41)

Properties navigation in bundled fork mode (Fixed)

Properties navigation now works in bundled fork mode. The fork’s findPosV adapter detects when moveVertically lands the cursor inside the frontmatter region or when the cursor is stuck at the boundary of the properties widget, and provides a focusBefore callback that focuses the “Add property” button in Obsidian’s metadata container. Both k and gk enter the properties panel — gk (moveByDisplayLines) checks focusBefore on the findPosV result, matching the existing check in moveByLines.

The stuckAtBoundary check uses range.head === startOffset to distinguish “cursor truly couldn’t move” from “cursor moved to a different display line within a wrapped line.” Without this guard, gk on a long wrapped first content line would fire focusBefore immediately instead of navigating through the wrapped display lines first — the cursor stayed on the same document line (pos.line === start.line) but at a different character offset.

The plugin’s tableAwareMoveUp motion (which overrides k when table navigation is enabled) bypasses findPosV with its own line arithmetic. To preserve frontmatter navigation, tableAwareMoveUp delegates to findPosV when the computed target line falls inside the frontmatter region, allowing the focusBefore callback to fire. (#25)

Source mode regression: The frontmatter interception fired unconditionally in both live-preview and source mode. In source mode, frontmatter is plain text with no properties widget — the interception found no focus target and left the cursor stuck below the frontmatter. Fixed by gating the entire frontmatter interception on Obsidian’s editorLivePreviewField state field via the fork’s new setLivePreviewField() API. In source mode (editorLivePreviewField = false), the block is skipped and the cursor moves through raw frontmatter text normally. (#77)

“Properties in document: Source” in Live Preview: When the editor was in Live Preview mode but Obsidian’s “Properties in document” setting was set to “Source”, frontmatter was rendered as raw ----delimited text. The .metadata-container DOM element still existed but was hidden (display: none). The focusBefore callback found the hidden element via querySelector, focused it (no visible effect), and moveByLines/moveByDisplayLines returned the original cursor position — leaving k, gk, and <Up> stuck. Fixed by adding a setPropertiesSource(fn: () => boolean) API to the fork. When the callback returns true, the frontmatter interception block is skipped entirely. The plugin passes () => getVaultConfig(app, 'propertiesInDocument') === 'source', evaluated per cursor movement so runtime setting changes take effect immediately. (#77)

Test coverage: test/specs/vim-builtin/g-commands.e2e.ts — 6 regression tests: gk navigates wrapped display lines before entering properties, gk enters properties on non-wrapping line, k enters properties from first content line, k moves up through source-rendered frontmatter (#77), k navigates through multiple frontmatter properties (#77), gk moves up through source-rendered frontmatter (#77).

Latex Suite interaction in bundled fork mode (Fixed)

The fork’s keydown handler now uses a CM6 eventObservers.keydown (DOM event observer) instead of eventHandlers.keydown. In CM6’s dispatch order, observers run before handlers, guaranteeing vim processes keys first regardless of Prec ordering or plugin load order. This eliminates the previous dependency on Obsidian’s community-plugins.json ordering. Latex Suite’s auto-snippets, tabstop navigation, and math-mode features work normally in vim insert mode. (#107)

Visual line navigation and replaced widget decorations (Fixed)

gj/gk (and j/k when mapped to gj/gk) now correctly navigate into block MathJax ($$) and other replaced widget decorations in Obsidian’s live preview. Previously, CM6’s moveVertically treated replaced decorations as atomic, causing the cursor to skip over the entire widget’s source range in a single step.

The fork’s findPosV applies three corrections to CM6’s moveVertically result:

  1. Multi-line jump clamp: When moveVertically jumps more than one document line and no fold exists in the skipped range, the cursor is clamped to the adjacent document line (±1). This prevents line-skipping on both replaced widgets (MathJax) and variable-height lines (headings with larger fonts).

  2. Tall non-wrapped line detection: When moveVertically stays on the same document line (lineJump === 0) but the Y coordinate change is less than half of defaultLineHeight, the cursor is “stuck” on a tall non-wrapped line — headings with large font size and/or line-height produce line blocks taller than defaultLineHeight, causing moveVertically to take multiple steps through the block even though the text doesn’t wrap. The fix detects this via coordsAtPos comparison and force-moves to the adjacent document line. Legitimate within-line moves (wrapped display lines) produce Y deltas greater than the threshold and are not affected.

  3. Column 0 fallback: When moveVertically correctly crosses one line but drops the cursor at column 0 despite a non-zero goalColumn, posAtCoords resolves the correct character position from the pixel X coordinate.

(#26)

Test coverage: test/specs/widget-navigation.e2e.ts (6 tests covering gj/gk/j/k through single and multiple $$ blocks), test/specs/vim-builtin/g-commands.e2e.ts (7 tests covering gk/gj horizontal position preservation across h1–h6 headings and mixed heading/list/text documents), test/specs/gk-column-drift-issue26.e2e.ts (2 tests covering the reporter’s exact content with consecutive h2 headings, a long wrapped line, and empty lines), and test/specs/gk-theme-variations.e2e.ts (2 representative theme-geometry variants).

Block visual mode (CTRL-V) insert not supported (Fixed)

Status: Fixed. Block insert, change, cursor positioning, and zero-width blocks all match Neovim. Zero deviations remaining.

I and A in block visual mode (CTRL-V) previously did not enter insert mode with aligned cursors on every selected line. Six fork-level fixes were required:

  1. enterInsertMode preserves wasInVisualBlock before exitVisualMode clears vim.visualBlock, so multiSelectHandleKey routes subsequent insert-mode keys correctly through CM6’s native multi-selection text input.
  2. selectForInsert skips short lines instead of clipping the cursor to the line end. Lines shorter than the block column are left unchanged, matching Neovim.
  3. operators.change block visual path uses cm.replaceSelections() to delete the block selection before entering insert mode at the block’s left column. Handles both c (change block) and C (change to EOL via applyOperator’s linewise head extension).
  4. exitInsertMode uses blockInsertLeft to position the cursor at the block’s original left column instead of the standard ch - 1. This fixes A cursor placement after <Esc>.
  5. makeCmSelection zero-width block fix changes fromCh < toCh to fromCh <= toCh so that zero-width blocks (fromCh === toCh) correctly include the character at the cursor position instead of creating a backwards range.
  6. repeatInsertModeChanges cursor positioning uses blockInsertLeft (stored on lastInsertModeChanges) for the final cursor position after dot-repeat, instead of a hardcoded +1 offset.

CM6’s native multi-cursor support means typed text appears on all lines in real-time (unlike Neovim, where text is only visible on the primary cursor until <Esc>).

Block visual operations that were already working: delete (d), yank (y), paste (p/P), indent (>/<), replace (r), case toggle (~), corner swap (o/O). Now also working: insert (I/A), change (c/C).

Test coverage: test/specs/vim-builtin/visual-block-golden.e2e.ts — 15 golden Neovim comparison tests covering block 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, upward selection, $ escape cursor position, and $ delete to EOL.

:sort cursor positioning (Fixed)

Status: Fixed. :sort (and ranged :2,3sort) now positions the cursor at the first line of the sorted range via cm.setCursor(), matching Neovim. Previously the cursor stayed at line 0 regardless of the sort range.

CTRL-V $ d cursor overshoot (Fixed)

Status: Fixed. 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 cursorMin(head, anchor) preserved the original anchor column, which could exceed the shortened line length after deletion.