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()andvim.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. Withwildoptions=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()usesrequestMeasure, 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:
| Site | Assumption |
|---|---|
src/rpc/document-sync.ts | 15 references to a single this.buffer and 10 to a single this.editorView |
src/rpc/companion.lua | 23 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.ts | nvim_win_get_cursor(0) and nvim_win_set_cursor(0) throughout, so “the window” is implicit |
cmdline, popupmenu, decorations, obsidian-feature-bridge | each 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)
vim.lsp.enable() attaches to the mirror buffer only on the first activationA 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:
| Behaviour | Why it is manual | How to check |
|---|---|---|
| Automatic input-method switching | Shells 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 shape | Rendered 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)
vim.opt options not registeredStatus: 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)
preVimrcSettings shallow copy bugStatus: 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.,
dfollowed by an expr-mapped key), the returned keys execute independently. The pending operator state is cleared before the callback runs.vim.v.operatoris still readable inside the callback. - Count is not auto-forwarded to expr results — Typing
3KwhereKis expr-mapped and returns'j'will executejonce, not three times. Include the count in the returned keys:return vim.v.count1 .. 'j'.
vim.v limitations
vim.vvalues in async callbacks are only reliable before the first yield — In async (non-expr) callbacks,vim.v.count,vim.v.register, andvim.v.operatorreflect 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.countreturns 0 outside callback context — Readingvim.v.countfrom a timer, autocmd, orvim.schedulecallback 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 customfoldtextLua callback is added (deferred to statuscolumn/foldtext v2). Note:foldlevelcalculation 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()instatuscolumn.ts) is identified; the variables have no consumer until Lua expressions are supported in the format string.vim.v.char— character typed duringInsertCharPreautocmd. Currently writable but never set by the plugin. Will be populated whenInsertCharPreis added to the supported autocmd events (requires a fork hook into insert-mode character input). Not in the current 19-event list.: Fixed. Returnsvim.v.insertmode'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 onlua/. - 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’slua/.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
— Fixed for mark reads: line 1 / UTF-8 byte column 0,nvim_buf_get_markreturns character offsets rather than byte offsets{0,0}when unset, andv:maxcolfor a linewise end. Mark setters remain a separate deferred seam.nvim_buf_get_option/nvim_buf_set_option/nvim_get_option/nvim_set_optionare deprecated in Neovim (replaced byvim.bo/vim.o). Provided as compatibility aliases.nvim_feedkeysonly supports mode flags'n'(noremap) and'm'/''(remap). Other flags ('t','i','x','!') are ignored with a console warning.nvim_echomaps to Obsidian’sNoticeAPI — 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. : Fixed. Supported notation now produces Neovim key bytes, andnvim_replace_termcodesis an identity functionnvim_feedkeysdecodes them into the fork’s notation at the boundary. Unknown notation stays literal.from_partis ignored; unlike Neovim,<lt>expands withdo_lt = trueeven whenspecial = false.vim.on_keyobserves pre-mapping input, unlike Neovim’s post-mapping hook. Mapped expansions and programmaticfeedkeysare 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.iterhas three extensions —rpop,count, andsizeare not present in Neovim 0.12.rpopaliases tail-removingpop;countdrains the iterator;sizedoes 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.goread engine values first, then a shared shadow store, then defaults foreventignore,selection,cmdheight,columns,lines,cpo, and theme-derivedbackground, otherwisenil.columns = 80andlines = 24are fallback values, not measured dimensions.operatorfuncnow shares real read/write handling acrossvim.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)andnvim_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 inclusivetopline/botline, or an empty list for a non-zero handle or no active editor. - Synthetic window identity and dimensions —
nvim_win_is_validaccepts 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 andwinnr('#')returns 0. These do not add multi-window handles. - D4: interior-byte cursor writes deliberately differ from Neovim —
nvim_win_set_cursoraccepts 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_textnormalizes 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_texthonors interior bytes exactly by slicing the UTF-8 encoding. This asymmetry is intentional: fengari holds Lua strings asUint8Array, 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:
curswantreports the fork’s partial goal state — a deviation, not parity —getcurpos()returns five elements; the fifth reads sticky goal state fromvim.lastHPos(not pixel-valuedlastHSPos). A stored goal survives shorter lines and is converted to 1-based; the fork’sInfinityafter$maps to2147483647. WhenlastHPosis-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_colup, following D5; getters return normalized byte columns, not the original interior bytes. Both endpoints accept0..byteleninclusive 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 fornvim_buf_get_text/nvim_buf_set_text,getpos/getcurpos/setpos, and extmark setters/getters, subject to the deviations above. Extmarkdetailsnow serialize as Lua tables with byteend_colandvirt_textchunk pairs.- Only enumerated coordinate APIs have the byte contract —
nvim_buf_set_mark,cursor, view save/restore,wincol,searchpos, and JS-backedstrlen/strpart/stridx/strridxremain deferred. D1 governs the manifest’s 23 enumerated APIs, not the whole shim. vim.bo.commentstringdefaults to%% %s %%(Obsidian-native comment syntax) for all buffers. Treesitter-contextual commentstring (e.g.,// %sinside a JS code block) is not implemented.vim.is_callabledoes not detect callable tables with__callmetamethods in the fengari sandbox. Only functions returntrue. (Note:rawget/rawset/rawequalare now available in the sandbox for Neovim compatibility, butvim.is_callablestill uses a function-type check rather thanrawgeton the metatable.)lockmarksex command modifier is silently stripped invim.cmd(). Marks are not preserved during buffer edits performed vialockmarks.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 tolua/. RequirespluginAutoFetchsetting to be enabled.require()supportsinit.luafallback — matches Neovim’s module resolution by tryinglua/name/init.luaiflua/name.luais missing.nvim_create_namespacereturns unique integer IDs per namespace name (matching Neovim). Previously returned0for all namespaces.- Extmark limitations:
nvim_buf_set_extmarksupportsvirt_text,virt_text_pos("overlay","eol","inline"),hl_group,end_row,end_col,hl_eol,priority, andid. Columns are bytes with the interior-byte normalization deviation above.sign_text,conceal,virt_lines, andline_hl_groupremain deferred; buffer handle must be0. Priority sorting is implemented but does not guarantee visual precedence over native CM6 decorations. nvim_buf_get_textreturns lines as a table (likenvim_buf_get_lines) with the range extracted. Buffer handle must be0.nvim_get_option_value/nvim_set_option_valuesupportscope = "global"andscope = "local". Both map to the same option store (Obsidian has no true per-buffer option separation except for options explicitly handled byvim.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 withasync 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. Returnsnilon 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)
query.get() does not load .scm files from the vaultStatus: 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 —
.scmedits 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
.scmfiles. 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 fromfolds.scmqueries 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>: ThejumpListWalkaction 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:
tableWidgetMode | enableTableNav | Experience |
|---|---|---|
native | true (default) | Full table-nav overlay with cell highlighting and structural commands |
native | false | Native table editor with vim cell editing and cross-cell h/j/k/l navigation, no nav overlay |
raw | either | Raw 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/lcross-cell navigation: In normal mode,h/lat cell boundaries move to the adjacent cell (same row).j/kat row boundaries move to the same column in the next/previous data row (separator rows are skipped).jat the last data row orkat the header row exits the table. When the cursor is not at a cell boundary,h/j/k/lmove 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
vimGlobalStatesingleton. 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/artable row text objects:irselects inner row content (between first and last|, excluding pipes),arselects the entire row including pipes. Works in raw markdown mode only — inside cell editors, the content doesn’t matchTABLE_REso 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 tol/h). Count prefix works (3Tabmoves 3 cells forward). -
Count prefixes not supported: Fixed.3jin 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 forh/lcolumn navigation (2lmoves 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/gEat cell boundaries now jump to the adjacent cell in normal mode. ThemoveByWordsmotion is overridden with the same cross-boundary pattern asmoveByLines/moveByCharacters. When the word motion result isnull(can’t move further) or the cursor is stuck at a boundary, the override crosses to the next/previous cell viagetNextCell. 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-cellh/j/k/lin visual/operator-pending mode). -
Count prefix on cross-cell motions: Fixed.3jin a cell editor now crosses 3 cell boundaries. ThemoveByLinesandmoveByDisplayLinesoverrides looprepeattimes throughgetCellBelow/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 withnew Scope()(no parent), disconnecting Obsidian’s global hotkey bindings from the keymap resolution chain. The keymap handler’sdefaultcase also consumed all unhandled keys. Fixed by parenting both scopes toapp.scope, returningfalsefor unhandled keys, and removingstopImmediatePropagation. (#146) -
Under the Neovim backend, an owned table is presentational.
tableWidgetMode: ownednow renders while RPC is connected, but the nested cell editor is deliberately inert —contenteditable=falseand 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: Fixed intableWidgetMode: owned@saberzero1/codemirror-vim@6.4.4. The nested cell editor did draw a caret and did hold focus; the fork’sBlockCursorPluginwas hiding it. Itsupdate()setdisplay: noneon every.cm-cursorLayer:not(.cm-vimCursorLayer)found in its ownscrollDOM— correct for its own view, wrong for a separateEditorViewmounted inside thatscrollDOM, whichquerySelectorAllreaches because a subtree query does not stop at an editor boundary. The nested editor draws its caret with plaindrawSelection(), having been built without the vim extension. The fork now skips layers whoseclosest('.cm-editor')is not its ownview.dom. This also explains why every measurement of that caret reported0x0: 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 nativedrawSelection()caret — measured1x19in 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’seffectiveVimStatereturns null withoutcm.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-writtenCmAdaptertype does not satisfy the fork’sCodeMirror. Closing it needs the parent’sinsertModeto be resolved correctly from the child’s update cycle, which the prototype did not achieve. -
tableWidgetMode: rawis 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 tonative, while an explicitly chosenrawis left alone. -
tableWidgetMode: ownedreplaces the table renderer, not Obsidian’s cell editor: parking the cursor in a table still constructs aTableCellEditor, and whileeditMode.tableCellholds 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 atcm.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;nullis 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,Vjdall measured). Insert-mode characters cannot be routed —handleKeyreturnsundefinedfor 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.ownedtherefore 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’svim.selline 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,IandAcarry structural meanings only where the overlay auto-activates, which isnative. Inownedthey keep their ordinary Vim meanings. The structural operations themselves are available there, through the:table*ex commands and their<leader>tbindings. -
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, withmainIndexpreserved; the nested editor also neededEditorState.allowMultipleSelections, without which CodeMirror keeps only the first range however many are dispatched. -
scrolloff=100disables horizontal scrolling inside a table innative— issue #167 item 6.ownedno longer reproduces it: the monospace grid setswhite-space: pre, so the nested editor does not wrap and still scrolls — measuredowned: 67againstnative: 0on 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-menupipeline), 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.
ownedreplaces that widget, so it does not happen. Realignment is available manually (:tablerealign,<Leader>tr,=in table-nav mode). Obsidian’s own formatter is reachable asTableEditor.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 capabilityownedmode loses. -
Snippets inside the owned surface are untested: Fixed. A prefix expands onTab,Tab/Shift+Tabmove between tabstops,Escapeends 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-modeTabnative 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
EditorViewper table cell, so every extension registered throughregisterEditorExtension()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 atisConnected: false, with no.cm-table-widgetancestor), 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-basedgutter()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 areStateFields or fold facets that receive anEditorStateand never see a view, one is a propertiesMutationObserveralready 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 misleadingskipInTableCells()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:
:wsaves the main document (expected).:qcloses 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 ownCursorControllerinstance — 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: fixedon.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-containerfor textarea overlays in modals). User keymaps are fully available — the codemirror-vim keymap is global (defaultKeymapat 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 anEditorView.scrollHandlerfacet that intercepts scroll requests during table-nav and adjustsscrollDOM.scrollTopbased 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. ReplacedMessageChannelwithrequestAnimationFrameto 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 hadmode: nullduring initialization causingisVimIdle()to return false. Fixed with a capture-phasekeydownlistener andisCellVimIdle()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 lockingscrollTopduring 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’sscrolloffvalue. Withscrolloff=999, the highlighted cell was not centered. Fixed by readinggetScrolloffMargin()in bothsyncCursorToActiveCellandtableNavScrollHandler. (#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 stalerequestMeasurecallbacks after focus transfer), the parent’sBlockCursorPluginrendered the unsuppressed cursor at the table-range position. Fixed by removing the prematureclearCursorSuppressedForView+resumeAnimatedCursorForViewfrom 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 hadoverflow: visiblewhich prevented any horizontal scrolling. Fixed by changing the widget tooverflow-x: autoduring table-nav mode and scrolling the widget element directly viascrollHighlightedCellIntoView(). (#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 theTableNavControllerViewPlugin is never destroyed or updated during view reconfiguration. Awindowcapture-phasekeydownsafety 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-focuseddynamic 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 (: Fixed. The native editor automatically escapes|) swallowed in table cells|as\|in the document source. -
: Fixed. The native editor handles<br>conversion in cell editors<br>↔ newline conversion automatically. ThecellBrToNewline/cellNewlineToBrutilities are removed. -
Visual mode highlighting in cell editors: Fixed. Charwise visual mode (v) in cell editors now shows selection highlighting via aCSSStyleSheetondocument.adoptedStyleSheetsthat forces::selectionvisibility in.cm-vimVisual:not(.cm-vimVisualLine)scoped to.vim-table-cell-editor. Linewise visual mode (V) uses the fork’slinewiseVisualHighlightViewPlugin, which is focus-independent (checksvim.visualLineandvim.selonly). (#19) -
Wikilink and formatting loss after cell edit: Fixed. Two issues: (1) the cell editor read the cell’s initial value fromwrapper.textContent(the rendered DOM), which strips markdown syntax —[[note-a]]becamenote-a. Now reads raw markdown from the document source viagetCellDocumentRange(). (2) On cell editor close, the cell content was restored as plaintextContentwithout re-rendering. Now usesMarkdownRenderer.render()to restore proper inline formatting (wikilinks, bold, italic, code) after the editor is destroyed. (#19) -
Tab cell navigation freezes editor: Fixed. PressingTabin insert mode inside an embedded table cell editor froze the editor — the cursor disappeared, vim mode got stuck in Insert mode, andEscapestopped working. Root cause:exitCellEdit()scheduled a 50msrefreshAfterOp()timer that was non-cancellable and had no state guard. WhenTabcalledexitCellEdit()→enterCellEdit()synchronously, the deferred refresh fired while the new cell editor was active — removing its key handlers, potentially orphaning the editor DOM, and leaving the controller in an inconsistent state. Fixed with defense-in-depth: cancellable/deduplicated refresh timer, state guard indoRefreshAfterOp,skipRefreshparameter for Tab transitions, and belt-and-suspenders timer cancel inenterCellEdit. 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 intable-utils.tsand 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()intable-nav-controller.tsqueried all.vim-table-renderedelements and returned the first match without considering which table the cursor was in. Fixed by adding atableFromparameter tofindWidgetEl()and using CM6’sview.posAtDOM()to correlate each widget element with its document position, returning the nearest match. Additionally, exiting cell edit mode on the second table caused the cursor to jump back to the first table. Two sub-bugs: (1)activeEditTableRangewas cleared before transactions that modify the document (closeCellEditordispatch,tableRealigndispatch), causingbuildDecorationsto create aDecoration.replacewidget for the active table and displacing the cursor — fixed by keepingactiveEditTableRangeset throughout the exit and refresh lifecycle, ensuring the StateField fast-path (prev.map(tr.changes)) fires during all document-changing dispatches. (2) AftertableRealign,doRefreshAfterOpusedArray.find()with a 200-position threshold to re-locate the table, which returned the first table within range rather than the closest — with two tables less than 200 positions apart, the first table always matched. Fixed by replacingArray.findwith 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
undoFileis enabled and the file is modified outside Obsidian between sessions, persisted ChangeSets become invalid (document length mismatch). The tree structure is preserved for:undolistdisplay, 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 (onactive-leaf-change). Dirty trees are persisted before eviction whenundoFileis 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-useaddToHistory.of(false)transactions, so pressinguafterg-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 viacoordsAtPos(). 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 defaultafter = truebehavior. 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 labelmatchfontsizeto 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 usingskipHiddenPrefix()to find the first visually visible character viacoordsAtPos(). (#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
dirattributes on.cm-lineelements, and CM6 offersEditorView.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 afterdf{char}{label}already works correctly — the fork stores the resolved position via_asyncMotionTargetandrepeatLastEditreplays the operator to the same relative offset. The label UI does not re-appear 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 witht/Ttill motions.Multi-line: Fixed. Whentcolumn 0t{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
flashjumpkeyat runtime requires a plugin reload or settings change that triggersreloadFeatures(). The key is bound viamapCommandduring registration. - Jump mode overrides
s: When enabled,sin normal mode triggers flash jump instead of substitute (cl). Visual modesretains its defaultcmapping. Jump mode: Fixed. The operator-prefix shadow resolver (see Operator-prefix key dispatch) automatically defers flash’ssconflicts with surroundcs/ys/dssmotion when surround’ss<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. BelowminPatternLength, 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()insrc/easymotion/targets.tsusedview.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, causingcoordsAtPos()to returnnullfor targets near the viewport top. Fixed by usingview.visibleRanges(which reflects actually-rendered document ranges) instead oflineBlockAtHeight. 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 (— Fixed. The fork now stores the resolved async motion position as a relative offset in.) does not replay operator-pending easymotion operationslastEditInputState._asyncMotionTarget. During dot-repeat,repeatLastEditapplies 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 theShiftkeydown event before the actual character arrived. The modifier-key guard (e.key.length !== 1) now suppresses modifier-only keys, matchingwaitForLabel()’s existing pattern. (#84)Inclusive motions (— Fixed. EasyMotion motions were registered with emptyf,t,e) exclude the target character in operator-pending modemotionArgs, so the fork treated all motions as exclusive. Added per-motionmotionArgs: { inclusive: true }matching native Vim semantics. (#109)EasyMotion line motions (— Fixed. Addedj/k) operate characterwise instead of linewise in operator-pending modemotionArgs: { linewise: true }to botheasyMotionLineandeasyMotionLineBackdefinitions.d<leader><leader>j{label}now deletes full lines (linewise), matching native Vimdjsemantics.EasyMotion forward motions do not set— Fixed. All directional EasyMotion motions now setmotionArgs.forwardmotionArgs.forward(truefor forward,falsefor backward), enabling the fork’sclipToLinefunction for forward cross-line operations.— Fixed.EXTRA_DEFSbidirectional motions lackmotionArgseasyMotionBdEndWord,easyMotionBdEndWORD,easyMotionBdTillnow setinclusive: true, andeasyMotionBdLinesetslinewise: trueviaObject.assign(motionArgs, defArgs)inside the motion function body. This bypasses themapCommandlimitation — the motion mutates the receivedmotionArgsdirectly, so operators apply correctly when invoked via Luavim.keymap.setremapping.— Fixed.easyMotionRepeatusescm.setCursor()directlyeasyMotionRepeatis now registered as adefineMotion(wasdefineAction) with amapCommandbinding at<leader><leader>.. The motion function inheritsmotionArgsfrom the last executed EasyMotion motion viaObject.assign, so operators liked,c,yapply correctly with the properlinewise/inclusive/forwardflags.
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 — 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 | in insert mode on a table line triggers automatic column realignment|| 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 — Fixed. The mid-edit | moves cursor to the left of || interception that caused cursor jumps has been removed. (#66)
Escaped — Fixed. Escaped pipes, wikilinks (\| characters treated as cell boundaries during editing[[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:
: Fixed.j/kcolumn trackingh/j/k/lnow cross cell boundaries in native table cell editors viadefineMotionoverrides. 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-incm-table-widget. Vim is injected into cell editors viaregisterEditorExtension(). The native editor handles wikilinks, pipe escaping, cursor positioning, and<br>conversion automatically. Cross-cellh/j/k/lnavigation is always active in native mode, independent of thetablenavsetting. -
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,coordsAtPoscollapses 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. Usenativeto edit. Gated to desktop, Live Preview, the bundled engine, and a disconnected Neovim backend; each blocker falls back tonativeand 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 mode | tablewidget | Result |
|---|---|---|
| Live Preview | raw | character appended at offset 36, the end of the document |
| Source | raw | |abc|defz| — correct |
| Live Preview | native | |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: (Fixed). exmap definitions only parsed during initial loadexmap 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:
| Condition | Notification | Suppressible |
|---|---|---|
| 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:
| Setting | Reason |
|---|---|
configMode | Circular dependency — can’t control config file loading from vimrc or init.lua |
leaderBindings | Already achievable via nmap <leader>x :command in vimrc or vim.keymap.set in init.lua |
pickerKeymap | Complex 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 resultgetKeymap('normal')shows theL → $entry after runtime application- After vimrc load,
vimrcMapsis empty andvimrcCommandCountis 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 theactive-leaf-changehandler
Mitigation (multi-layered):
stat()readiness probe:readVimrcFilenow callsapp.vault.adapter.stat(path)beforeread()to verify the file exists in the vault index. This avoids attempting reads on non-existent files and providesstat.sizeto distinguish genuinely empty files from timing-empty reads.- Two-phase parsing: File reading/parsing is decoupled from command application.
readAndParseVimrcFileparses the vimrc without needing a CM adapter;applyVimrcCommandsapplies all commands, deferring cm-dependent ones topendingExCommands. - Smart retry with backoff: When
stat.size > 0butread()returns empty (timing issue), retries with exponential backoff (50ms, 100ms, 200ms, 400ms — 750ms total). Genuinely empty files (stat.size === 0) skip retries entirely. - 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.
vimrcLoadingtry/finally: ThevimrcLoadingflag is now reset in afinallyblock, so a failedloadVimrc()call no longer permanently blocks future retry attempts on subsequentactive-leaf-changeevents.- Lua loader parity: The same
stat()+retry pattern is applied toreadLuaFile()insrc/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 send0x1b.<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.
\tfor table commands under leader\) - Non-leader groups: use the raw prefix (e.g.
grfor the replace-with-register operator,csfor surround changes) <leader>token: expanded to the actual leader key (e.g.<leader>tresolves to\twith 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 ..."): Whenvim.keymap.setuses a function callback that callsvim.cmd("ob ...")orvim.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 explicitdescoption. 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/:obpattern (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 foreignDocumentobjects — if Obsidian’s API referencesdocumentinternally, 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)
gt always goes to first tab / Ngt count ignoredStatus: 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/kscroll, 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:
| Key | Action | Behavior |
|---|---|---|
f | Activate | Click button, focus pane, navigate link, focus input |
F | Open new | Open target in new tab (Ctrl+Meta click for generic targets, openLinkText for links, duplicateLeaf for panes) |
yf | Yank | Copy URL for links, note path for tabs, display text for others |
df | Close | Close tab/pane via leaf.detach(); Notice for non-closeable targets |
gf | Context menu | Open 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 viasetActiveLeaf;Faction duplicates the leaf into a new tab viaduplicateLeaf).workspace-tab-header→tab(close vialeaf.detach())a[href],[data-href],.cm-underline,.cm-hmd-internal-link,.cm-link,.cm-url→link(internal links navigate vianavigateWithJump; external URLs open viawindow.open()).Fixed in three phases: (1).cm-underlinespans in Live Preview had nohrefordata-hrefattributes, causing wikilinks and markdown links to fall through to the generic click handler (no-op on CM6 spans).resolveCmUnderlineHref()usesEditorView.posAtDOM()to convert the DOM element to a document offset, then callsfindLinkAtCursor()to extract the link target from the raw markdown text. (2)Hint labels only appeared onFixed: added.cm-underlinespans, 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-linkspans and markdown links render as.cm-link/.cm-urlspans — neither was inTARGET_SELECTOR. In Source mode, wikilinks always render as.cm-hmd-internal-link..cm-hmd-internal-link,.cm-link, and.cm-urltoTARGET_SELECTORwith deduplication filters to prevent multiple hints per link (aliased wikilink sub-spans, nested.cm-underlineinside.cm-hmd-internal-link, markdown link URL spans when text span exists). (3)getEditorViewFromElement()falls back to theMarkdownView.editor.cmpath when the DOM.cmView.viewproperty is unavailable (which is the case in Obsidian’s runtime). External URLs resolved byresolveCmUnderlineHref()are opened viawindow.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 includeclientX/clientYfrom the element’s bounding rect center viagetElementCenter(), ensuring dropdown menus and popovers position correctly near the clicked element instead of at(0, 0). (#104)
Target discovery filters:
- Elements with
.is-measuringclass are excluded (Obsidian 1.13+ shadow<select>copies used for layout measurement) - Child elements inside
.checkbox-containerare excluded (the container itself is the clickable toggle, not its inner<input>) input[type="hidden"]and disabled elements are excluded.cm-underlineinside.cm-hmd-internal-linkis excluded (parent is the preferred target).cm-formatting-linkspans are excluded (bracket characters[,],(,)should not be hint targets)- Only the first
.cm-hmd-internal-linksibling per link group is kept (aliased wikilinks[[Target|Alias]]produce 3 sub-spans) - Only the first
.cm-linksibling per link group is kept (formatting brackets produce separate.cm-linkspans) .cm-urlwith.cm-stringclass is excluded (URL inside markdown link parentheses — the.cm-linktext span is the hint target). Bare URLs (.cm-urlwithout.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.
Modal behavior
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)
2F) shifts focus to new tabStatus: 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> :forwardgf 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:
| Feature | Desktop | Mobile (enabled) + physical keyboard | Mobile (enabled) + soft keyboard | Mobile (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/A | N/A | N/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
| Command | Neovim description | Why N/A |
|---|---|---|
:!{cmd} | Execute shell command | Obsidian has no shell access (sandboxed Electron app) |
:read !{cmd} | Insert shell output | No shell access |
:terminal | Open terminal | No terminal emulator in Obsidian |
:cd / :lcd / :pwd | Change/show working directory | Obsidian vault is the working directory; no directory navigation |
:make | Run build | No build system concept |
Quickfix / location list
| Command | Neovim description | Why N/A |
|---|---|---|
:cnext / :cprev / :copen / :clist | Quickfix navigation | No quickfix or error list (Obsidian is not an IDE) |
:lnext / :lprev / :lopen | Location list | Same — no location list concept |
Tags / ctags
| Command | Neovim description | Why N/A |
|---|---|---|
:tag / :tjump / :tselect | Tag navigation | Obsidian has no ctags integration. gd provides link-based “go to definition” instead. |
Scripting / autocommands
| Command | Neovim description | Why N/A |
|---|---|---|
:autocmd / :augroup | Autocommands | Obsidian plugins handle events via the Plugin API, not Vim autocommands |
:function / :call / :if / :for | Vimscript | The plugin is not a Vimscript interpreter. Use .obsidian.vimrc for mappings. |
Diff mode
| Command | Neovim description | Why N/A |
|---|---|---|
:diffthis / :diffsplit / :diffget / :diffput | Diff operations | No diff view in Obsidian’s editor |
Other
| Command | Neovim description | Why N/A |
|---|---|---|
:args / :argdo / :next / :prev | Argument list | No arglist concept — Obsidian manages open files via tabs |
:resize | Resize window | Obsidian manages pane sizing automatically |
:tabmove | Reorder tabs | Obsidian does not expose a tab reorder API |
:view | Open file read-only | Obsidian has no read-only mode for notes |
:bunload | Unload buffer from memory | Obsidian manages editor memory internally |
:menu | Create GUI menus | No Vim-style menu system |
:spell* | Spelling commands | Obsidian has its own built-in spell checker |
Behavioral deviations
These commands exist but behave differently from Neovim:
| Command | Neovim behavior | Obsidian behavior | Reason |
|---|---|---|---|
Y | Mapped to y$ by default | Mapped to y$ by plugin (overrides CM Vim’s yy default) | Follows Neovim convention per design principle #2 |
Q | Replay last recorded macro | Mapped to @@ by plugin (overrides CM Vim’s unmapped Q) | Follows Neovim convention |
:wall / :wa | Save all modified buffers | Saves only the current file | Obsidian auto-saves; a true “save all” would need to iterate all leaves |
gf | Open file path under cursor | Opens Obsidian quick switcher | Wikilinks (gd) are more natural for note navigation |
zO / zC / zA | zo/zc/za | Fixed. zO/zC/zA/zD now operate recursively using range containment on CM6’s foldable regions. | |
zn / zN | Fixed. zn/zN/zi implemented via foldEnableField StateField. Fold gutter arrows remain visible (shows foldable regions) but fold operations are suppressed. | ||
it / at | HTML tag text objects (CM Vim native via XML mode) | Plugin-implemented via raw text scanning | CM Vim’s expandToTag requires findMatchingTag/findEnclosingTag functions from a parser mode not active in Markdown |
Ex :m/:t address parsing (Fixed)
:m/:t address parsingStatus: 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=mousedoes 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/:sunmapfallback to:vmapwhen no select-specific mapping exists (matches Neovim).selectmode=keyandkeymodel=startseloptions 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;
gRdoes 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 —findSurroundingBracketsparameter swap fixed - Cursor position after
ys/yss/visualSnow atch:0(on the delimiter) — matching nvim-surround ds(on nested parens and multiline content now workscs({now correctly finds and changes parens to braces with spacesds}space preservation — closing-bracket forms now preserve inner spaces (opening forms still strip)cschained operations —_surroundReplacementno longer leaks between different surround operation typescsdot-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 — wherechangeSurroundPairparks it — socsba..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) yswith line-crossing motions —ysjb,ys2jBcorrectly expand to full lines for linewise motionsySS/VSBnewline 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 detectioncsbBysaBbchain —yswith text object motions (aB,iw) aftercsnow works. Theys_motionhandler directly evaluates text object motions instead of dispatching through the fragilehandleKey→evalInputpath whereclearInputStatewould lose theselectedCharacter.yswith the plugin’s Markdown text objects —ysi$,ysa$,ysi=,ysi~,ysi_,ysil,ysiC,ysio,ysi,and the rest now work. Theys_motionhandler 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 pendingysifrom 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 (aBis the blockquote object, not the{}block) only wins where it actually matches. (#179)
Remaining deviations (3 cases):
| Category | Count | Description |
|---|---|---|
ys dot-repeat with tag/function | 2 | ysiw<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 difference | 1 | Intentional: fork treats < as angle bracket; nvim-surround treats it as tag prompt (no-op) |
Fixed (previously listed as deviations):
— Fixed for simple delimiters (ysdot-repeat with text objectsysiwb,ysiw",ysaw',ysiw]). Fork stores text object motion characters inlastEditInputState._ysTextObjectMotionand_ysTextObjectCharvia theonRepeatcallback. During dot-repeat,repeatLastEditre-evaluates the text object at the current cursor position and appliesaddSurroundToRange(). Fork tests: 1806/0 (was 1803/3).Tag— Verified working. Fork tests (cst/yst(change/add tag)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 (— Implemented blockwise replacement with register line duplication/truncation and per-line replacements.<C-V>+gr)
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
iB does not scope to innermost blockquote nesting levelStatus: 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
di* operates when cursor is on the delimiterStatus: 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)
cw + typed text unreliableThe 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)
) sentence motion cursor position at end of textFixed 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)
n/N search wrap-around unreliableThe 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 ch | Neovim heading ch | Obsidian heading ch | Δ (Obsidian) |
|---|---|---|---|
| 6 | 6 | 8 | −2 |
| 11 | 11 | 11 | 0 |
| 16 | 16 | 15 | 1 |
| 21 | 21 | 18 | 3 |
| 26 | 22 (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/Fin replacement position triggers function wrapping (was literalf/Fas delimiters).Sin visual mode now surrounds instead of substituting (wasS→VdOkeyToKey).
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)
bufferoption not supported (Obsidian has no buffer numbers)commandoption not supported (usecallbackonly)nestedoption not supportedbuffield in event data is always 0TextYankPostrequires 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 NeovimTextYankPostnotification 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 (. Fixed. All 5 events now fire per-view via TextYankPost, CursorMoved, CursorHold, CmdlineEnter, CmdlineLeave) are still active-leaf-onlyAutocmdEventWatcher 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 corelocal-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 bytest/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() broken at runtimevim.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 leader bindings not in which-keyvim.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:
— Fixed.keymap.del+Q: plugin’s built-inQ→@@mapping persists after Lua unmapreloadFeatures()now re-applies Lua map operations (including unmaps) after re-registering built-in mappingscw+<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
| Feature | Reason |
|---|---|
require() / plugin loading | Security — 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:
| Function | Notes |
|---|---|
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)
g.) has no effectStatus: 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)
<CR> opens file in new tab instead of same leafStatus: 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)
<C-t>/<C-s>/<C-h> keybindings do nothingStatus: 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/willDeleteFilesfor 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)
:Oil . opens current file’s directory instead of vault rootStatus: 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)
oil~ prefixStatus: 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 viavim.keymap.set('n', 'key', ':excommand<CR>')in Lua ornmap 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>/qclose,gxopen external). Visual mode multi-select (V+<CR>opens all selected files). Buffer-local remapping viaOilEnter/OilLeaveautocmd 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’scommandLabelsmap. When oil bindings are dynamically mapped (onOilEnter), they appear invim.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 (— Implemented.g?in oil context) is planned but not yet implementedg?opens aVimInfoModallisting all oil keybindings in a Key/Action table, following the same pattern used by:marks,:buffers, and:registerswhen the picker is disabled. Dismissible via Escape.
Which-key and g? in non-editor context (Fixed)
g? in non-editor contextInvestigation (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 adata-vim-marksattribute and CSS::afterpseudo-element. Zero layout shift — marks overlay the existing gutter without adding a column. Toggle viaenableMarkGuttersetting (default: on). - Global mark persistence: Marks
A–Zare stored in plugin settings (persistedMarksarray) with file path and cursor position. Saved via 30-second polling interval with dirty-flag check, plus immediate save ononunload(). Marks survive file closes and plugin restarts. - Grouped marks picker:
:marksshows 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:markspicker under a “Special marks” group. They are read fromcm.state.vim.markslike buffer marks.Global mark file rename— Fixed.MarkStore.renamePath()is called from thevault.on('rename')handler, andMarkStore.removeByPath()fromvault.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 firesvim-command-done. - Gutter refresh mechanism — the gutter reads
cm.state.vim.markson eachvim-command-doneevent. Position tracking through document edits usesDecoration.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
CursorMovedlistener). 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
MarkdownViewfiles (PDFs, canvas, images don’t have a text cursor). - Sparse slot arrays — removing a pin sets its slot to
nullwithout 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.tsxranks aboveheader/utils.tsfor query"Header"). Supports typo tolerance via single-error mode, configurable fuzziness, and multi-word queries. - obsidian: Obsidian’s built-in
prepareFuzzySearchAPI. 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
— Fixed.:grepis fuzzy, not regex:grepnow uses JavaScriptRegExpfor pattern matching, matching Neovim’s:grepbehavior (which uses the externalgrepprg). Invalid regex patterns gracefully fall back to substring matching.:marksshows 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 persistedMarkStore.:marksin 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 sinceMarkdownRendererstrips 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 onSUITES.find()— if a suite name is renamed intest-definitions.tsbut not in the spec file, the test runner produces an explicit failure instead of silently generating zero tests - Golden mode comparison added to
testWithNeovim()— golden data already containedmodevalues but the CI comparison path only checkedcontentandcursor. Mode mismatches are now caught - 33 deviations classified with a
categoryfield (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 asinfra-limitationdeviations:gh/gHselect mode (6 tests — spike confirmedhandleKeycannot enter select mode viag+hdispatch),N after / search(1 test — CM6 search panel timing),<C-a>re-insert previous insert (1 test),dgn/cgnsearch-match operators (4 tests),<C-G>uinsert undo break (1 test), and0<C-D>/^<C-D>insert indent deletion (2 tests). Previously 10 — resolved 3:V3j+J,vip+d,v+r,v+aw+dfixed viauseHandleKeyflag +vimHandleKeyshelper (dispatches all keys throughVim.handleKey()synchronously, bypassing DOM event timing);lua nmap change wordfixed via key-string encoding (<Esc>literal →\x1bbyte);lua leader key mappingreclassified toupstream-bug(leaderRegistry propagation timing, not test dispatch);vt.+dandv$+dexposed as genuineupstream-bugbehavioral deviations (visual-modetrange andv$dcursor position differ from Neovim) - 3 vimrc
setoption tests (lines 109, 116, 121 invimrc.e2e.ts) remain asassertPluginLoaded()-only. Root cause confirmed: the vimrc I/O timing issue (documented under set textwidth via vimrc).SideEffectOptoptions (clipboard,expandtab,textwidth) are applied viaonSettingOverride→applySettingOverride→this.settings[key] = value, butsaveSettings()strips these overrides usingpreVimrcSettings, and theinitializingflag duringonload()gates the override chain. By the time the test readsplugin.settings.clipboard, it returns''(the pre-vimrc default).Vim.getOption('clipboard')also returns''. ThevimrcOverridesMap is empty by test time. The Luavim.optequivalent is verified and passing inlua-config.e2e.ts— the Lua path works becauseloadLuaConfigForTest()runs the override chain synchronously without theinitializing/saveSettingsstripping (Fixed) — the diagnosis above was wrong, and the crash-guard is why.:changesex command test remains a crash-guard. The correct modal selector is.vim-motions-info-modal… theVimInfoModal.open()call completes without error but no.vim-motions-info-modalappears in the document. Root cause likely related to the ex-commands test’shandleExwrapper or test stateVimInfoModal.open()was never called::changeswas registered only insetupVimSubsystems(), andreloadFeatures()tears every registration down and rebuilds only those registered throughregisterExCommands(). Ex teardown did not remove the command — it re-defined it asnoopEx— so:changesstayed recognized (the fork never reportedNot 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’sundefineEx()on teardown and registering:changesin the rebuilt path. The test now asserts the modal title is absent before the call and present after(Fixed) — also misdiagnosed.:e!and:updateex command tests remain crash-guards. Confirmed:setupEditor()usesview.editor.setValue(text)which sets in-memory content only …:e!reverts to the last saved disk state, not thesetupEditorcontent:e!was not reverting to anything: the fork’sparseInput_ends a command name at the first non-word character, so:edit!parsed as commandeditwith argument!and the separately registereddefineEx('edit!', …)handler was unreachable dead code. The command created and opened a junk note named!.mdand emptied the buffer.:updatewas 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()comparescontent,cursor,mode(mode comparison added this release). The golden schema (GoldenCase) and recording infrastructure now captureregisters(unnamed register text + linewise flag) andvisualMode(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 (eachtestWithNeovimtest 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)
s (substitute) test failureStatus: 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:
| Area | Fork tests | Reason not golden-verifiable |
|---|---|---|
Scroll/viewport (zz, zt, zb, Ctrl-d/u/f/b) | 9 | Depend 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 golden | Fold 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) | 1 | Single test for cross-document marker invalidation — Neovim doesn’t share the CM6 Marker/posFromIndex infrastructure |
Cursor rendering (rendered_cursor_position_*) | 2 | Test .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>jlthenvorVfollowed bydproduced 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_processand 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).CmdlineEnterdoes not trigger an IM switch (users may need CJK input for search queries). The global ex command modal (:in non-editor views) does not fireCmdlineEnter/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 viasaveData()(30-second interval + save on unload). The saved IM is restored on plugin load.: Implemented.:IMToggle/:IMStatusex commands:IMToggleenables/disables IM switching.:IMStatusshows 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.imwith cursor position checks in Lua autocmds. CmdlineEnter/CmdlineLeavefor global ex command modal: The global:modal in non-editor views (ObsidianSuggestModal) does not fire cmdline autocmd events. Only the codemirror-vim editor dialog fires them.CmdlineChangedevent: An autocmd event that fires on each keystroke in the command-line prompt. Not needed for IM switching but useful for advanced Lua scripting.cmdlinetext in event data: Including the actual command text inCmdlineLeave’s event data. Currently onlycmdtype(:,/,?) 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 becauseCmdlineLeavefires when the dialog closes (abandoning any active composition), but a more complete solution would track composition in the search input too. : Implemented. The function now takesloadInitLua()parameter refactor(app, vim, options?)with aLoadInitLuaOptionsinterface.
Intentionally not supported
These features are excluded by design and will not be implemented:
| Feature | Reason |
|---|---|
jscommand / jsfile in vimrc | Security risk — arbitrary JavaScript execution |
cmcommand in vimrc | Broken in CodeMirror 6, never fixed upstream |
| Built-in since v0.51.0 — see Settings → Vim Motions → Input method | |
| Built-in since v0.47.0 — see Settings → Vim Motions → Vim features → Yank highlight | |
| Reading view navigation | Use the vim-keynav plugin |
| Vim toggle command | Use the vim-toggle plugin |
| Canvas keyboard navigation | Canvas 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()andplugin.getState()on the plugin instance — not part of the officialTasksApiV1(which only exposes modal and toggle methods). Theobsidian-tasks-plugin:cache-updateworkspace event is also undocumented. - Dataview: Uses
DataviewAPI.pages()— a well-established public API exposed viawindow.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:
| Feature | Rationale |
|---|---|
| Dataview DQL query execution | The 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 picker | Write 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 plugin | Separate 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 settings | Pre-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 modes | The 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 plugins | The 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()andd()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
$CLIPBOARDon mobile —navigator.clipboard.readText()may be unavailable or permission-denied on mobile platforms and non-secure contexts.$CLIPBOARDresolves to''silently in these cases. On desktop, the clipboard is cached onwindow focusandvisibilitychangeevents and read synchronously at expansion time. Intra-app vimy/doperations do not trigger the cache refresh —$CLIPBOARDreflects the system clipboard at the last focus/visibility event.$TM_SELECTED_TEXT/$VISUALin tab-expand mode — tab expansion requires an empty selection (tab-expand.tsreturnsfalsewhen selection is non-empty).$TM_SELECTED_TEXTand$VISUALalways resolve to''in tab-expand mode. Use the:snippetcommand 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) readsview.state.selection.mainwhich is collapsed after visual mode exit. The:snippetcommand uses vim marks to recover the visual selection; the picker does not have access to thecmadapter. This is a latent issue — the picker is typically invoked from normal mode. snip.envfor Luaf()/d()callbacks — deferred. The currentf(args, parent)/d(args, parent, old_state)callback signatures do not carry environment variables. LuaSnip exposessnip.env.TM_SELECTED_TEXT,snip.env.LS_SELECT_RAW, etc. Adding this requires changes todynamic-bridge.tsand 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|. Andset tablewidget=owned, where the plugin owns the surface: expansion,Tab,Shift+TabandEscapeall work in a cell, and the active tabstop is selected there. Expansion inowneddid 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 inowned, so a body containing a literal|opens an extra column and one containing a newline does not survive.set tablewidget=rawis not a workaround — see the table-widget section below. (#198)
Ex command snippet expansion (Fixed)
Fixed. The commands were silently broken after any :snippet <name> and :snippets (picker) commands are registered but expansion via the test harness’s Vim.handleEx() bridge does not produce visible results.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>, andcontenteditableelements 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.
- Insert mode → Escape: Enters normal mode within the overlay (vim handles it).
- 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
contenteditablesupport — 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.valueprogrammatically 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
focusinlistener is installed on the main document only. Textareas in popout windows are not detected. maxlengthnot enforced — textareas with amaxlengthattribute are not constrained in the CM6 overlay. Content exceedingmaxlengthwill 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.activeEditornot set — the overlay usesskipActiveEditor: trueto avoid interfering with Obsidian’s editor tracking. Plugins that checkworkspace.activeEditorwill 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:
- Lua code calls a C-function (e.g.,
vim.ob.fs.read) that yields the current coroutine - The JS host receives the yield with a Promise attached
- The JS host
awaits the Promise - The JS host calls
lua_resumewith the resolved value - Lua code continues as if the function returned synchronously
Challenges:
- Restructuring
engine.tsexecution 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.luawould need implicit wrapping) - Error handling across the yield/resume boundary
- Instruction count hooks (
lua_sethookwithLUA_MASKCOUNT) must persist across yield/resume cycles - Interaction with
vim.schedule/vim.defer_fn/vim.uvtimer 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_MININTEGERwidened to ±(2^53 - 1) (symmetric bounds)- All
|0arithmetic truncation removed from the VM, lobject, lbaselib, lmathlib string.pack/unpackSZINTchanged from 4 to 8 —string.packsize("j")returns 8- Table keying, API validation, and string parsing updated for 53-bit range
sprintf-jsdependency 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 * bwhere both operands > 2^26 may exceed 2^53, silently losing precision. Standard Lua wraps via 2’s complement; this fork loses bits. math.ultsemantics 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()useslua_pushnumber(float), notlua_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 —__gcdoes not help with timer cleanup. Usetimer:close()explicitly, or rely onTimerManager.destroyAll()at plugin unload. __gcon tables is not supported (userdata only).- Finalization timing is non-deterministic —
FinalizationRegistrycallbacks fire between event loop turns, not during synchronous Lua execution. - Finalization order is unspecified —
FinalizationRegistryprovides 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-basedstart,endbyte offsets, ornilre:match_pos(str, start?)→ match starting from offset (default 0), returns 0-basedstart,endornilre:replace(str, repl)→ returns new stringre: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
| # | Improvement | Effort | Impact | Status |
|---|---|---|---|---|
| 1 | Coroutine↔Promise bridge | High | Critical | ✅ Implemented |
| 2 | Custom require() for vault Lua files | Medium | High | ✅ Implemented |
| 3 | 32-bit → 53-bit integers | Medium | High | ✅ Implemented |
| 4 | __gc via FinalizationRegistry | Medium | Medium | ✅ Implemented |
| 5 | JS RegExp exposed to Lua | Low | Medium | ✅ Implemented |
| 6 | Re-enable load() with sandboxing | Low | Medium | ✅ Implemented |
| 7 | sprintf-js replacement | Low | Low-Med | ✅ Implemented |
| 8 | Error message quality | Low | Medium | ✅ Implemented |
| 9 | Weak tables via WeakRef | High | Low-Med | Not started |
| 10 | collectgarbage("count") | Low | Low | ✅ 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
pasteaction usesnavigator.clipboard.readText()asynchronously for system clipboard registers ("+p). The paste override captures state viasetTimeout(0), which may fire before the clipboard Promise resolves. Cycling after system clipboard paste is unreliable. Non-text clipboard content (images) silently ignored by: Fixed. Whenpclipboard=unnamedorunnamedplusis set and the system clipboard contains non-text content (e.g., an image),ppreviously did nothing —readText()returned""andcontinuePaste()bailed silently. The fork now falls back todocument.execCommand('paste')whenreadText()returns empty or rejects, triggering Obsidian’s native paste pipeline (attachment creation +![[Pasted image …]]insertion). Coversp,]p,[p,:put, and explicit"+p. The editor stays in normal mode after the fallback.P/gp/gP(overridden by the host plugin’spasteFromRegister()) are not affected — they read from internal registers only and do not consult the system clipboard.Workspace navigation dependency:Fixed (#165).P,gp,gPpaste actions are defined byregisterWorkspaceNavigation(). If workspace navigation is disabled (enableWorkspaceNav=false), these actions are not registered and cycling after them silently fails. Cycling afterpstill works (fork’s built-in action).P/gp/gPare now registered byregisterCoreVimActions()which is always called regardless of the workspace navigation setting.Dot-repeat: PressingFixed. On cycling exit, the final cycled content is written to the original paste register. The fork’s.after cycling repeats the original paste, not the final cycled text.repeatLastEditre-reads the register at replay time, so.pastes the final cycled text. Follows yanky.nvim’supdate_register_on_cyclesemantics. System clipboard registers ("+/"*) are excluded.- Undo grouping: Each cycle replacement uses
addToHistory.of(false)so it does not create a separate undo entry. Pressinguafter cycling undoes the entire paste+cycle sequence. - Register traversal: Only numbered registers
"1–"9are 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: Fixed. All 8 animated cursor settings are available viavim.opt/ vimrc configurationset smoothcursor/vim.opt.smoothcursorand 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.popoveror.modal-containerare detected by theCursorControllerconstructor (isAboveCanvasflag) and fall back to the fork’s native vim cursor viasetCursorSuppressedForView(view, false). Thetick()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-vimModeDOM 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 checkingthis.cm.state.vim.insertModedirectly. Also usessetProperty("caret-color", ..., "important")for CSS specificity robustness. (#130)- Fork:
~/Repos/codemirror-vim/src/block-cursor.ts(BlockCursorPlugin.update()— vim-state-basedcaretColor)
- Fork:
Escape does not close footnote popover: Fixed. The fork’sfindKeyconsumed<Esc>unconditionally in idle normal mode. Fixed viasetIdleEscapeCallbackAPI — the plugin registers a callback that callsHoverPopover.hide()for non-workspace-leaf editors (popovers, modals) while silently consuming Escape in workspace-leaf editors. (#130)- Fork:
~/Repos/codemirror-vim/src/vim.js(setIdleEscapeCallbackAPI) - Plugin:
src/vim/escape-guard.ts(installEscapeGuard)
- Fork:
Stale cursor suppression after animated cursor toggle: Fixed.CursorControllerconstructor now gates suppression onconfig.enabled;update()clears per-view override when disabled. (#130)Cursor flashing in Normal mode after table interaction: Fixed. Four issues: (1)mainEditorTableCursorGuardandcellEditorCursorGuardintable-cell-cursor-guard.tsusedsetCursorSuppressedForView(view, false)to unsuppress the cursor when leaving a table — this sets an explicit per-view override that conflicts with the animated cursor’s globalsetCursorSuppressed(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 staletrueoverride through plugin recreation. (3)cellEditorCursorGuard.update()force-unsuppressed the cell cursor on every update cycle (same anti-pattern removed fromCursorController.update()in commit 62444df). (4)reloadFeatures()did not callsetCursorSuppressed(this.settings.animatedCursor)— the global suppression flag was only set duringonload(), so any runtime setting toggle viareloadFeatures()left the global flag stale. All unsuppress paths intable-cell-cursor-guard.tsandtable-nav-controller.tsnow useclearCursorSuppressedForView()(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’sBlockCursorPlugin.update()relied on a CSSbaseThemerule (.cm-vimMode > .cm-cursorLayer:not(.cm-vimCursorLayer) { display: none }) to hide native cursor layers, but CM6’sdrawSelectionextension 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 settingcaretColor: transparenton everyupdate()call, regardless of suppression state. In insert mode with bar cursor (.cm-vimModeremoved, fork doesn’t render a fat cursor),caretColoris set tovar(--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-awarecaretColor) - Fork:
~/Repos/codemirror-vim/DIFFERENCES.md(updatedsetCursorSuppressedAPI section)
- Fork:
Count-prefixed and multi-key motions not animated: Fixed. Movements like4jandg$caused the cursor to teleport becauseresolveVimMode()usedvim.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 calledsnap(). Fixed by gating operator-pending detection oninputState.operatoronly. (#86)- Table navigation cursor hiding: Both native and animated cursors are hidden during embedded table navigation. The controller dispatches
enterTableNavso themainEditorTableCursorGuarddefers to table-nav, andsuppressWidgetCursorLayers()hides cell editor cursor layers on every ViewUpdate (cell editors are destroyed and recreated during entry, each spawning a freshBlockCursorPluginwith a visible cursor layer).cellEditorCursorGuard.destroy()guards onisTableNavActive()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 stalerequestMeasurecallbacks 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_CONTROLLERSis 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 ach !== '\n'guard that prevented stepping back fromsel.headwhen 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 thecoordsAtPos()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()viadomAtPos) for baseline calculation instead of relying solely oncoordsAtPos(). Falls back to thecoordsAtPos()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 fromcoordsAtPos()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 heartbeatsetIntervaldetects stalled loops (vialastLoopTimetimestamp 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. Avisibilitychangelistener re-wakes the loop when the page regains visibility. Canvas backing-store dimensions are rounded withMath.round()for fractionaldevicePixelRatioon 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 cachedgetComputedStyle,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 comparedscrollDOM.scrollTop/scrollLeftagainst its cached values, but it only runs when CodeMirror produces aViewUpdate. 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 intick()— 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()returnednullandrefreshTarget()returned early without clearingcachedRect, sotick()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 apassivescrolllistener onscrollDOMthat marks the position dirty and wakes the manager, and by clearingcachedRect/cachedShapeRectwhen 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(onScrolllistener,refreshTarget()clears the stale rect,coordsToRect()vertical intersection test)
- Plugin:
The character under a block cursor stayed behind when scrolling: Fixed.resolveBlockChar()cachescharTop/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 andfillTextpainted the letter where the caret used to be whilefillRectdrew the block at the new one. The dirty region reported for the frame covers only the block, so the stranded letter was outside every subsequentclearRect. 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)
- Plugin:
Settings that gate an editor extension did nothing until restart: Fixed. OnlysetupVimSubsystems()populatedvimExtensionSlot, and it runs fromonload()andenableVim()— never fromreloadFeatures(), which is what settings toggles call.animatedCursorwas the visible case: the toggle suppressed the fork’s cursor viareloadFeatures()and never installed the canvas meant to replace it, so the editor showed no cursor at all until Obsidian restarted.snippetTriggerModeandenableUndoTreewere silently restart-only for the same reason, andenableUndoTreeadditionally never reached a reload path.enableSnippetswas the least affected: its extensions also stayed installed, butcreateSnippetTabKeymapre-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, andteardownVimSubsystems()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 nestedExtension[]pushed intovimExtensionSlotonce, whose contents are swapped in place byrefreshRuntimeExtensionSlots()followed by a singleworkspace.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(enableUndoTreeadded toRELOAD_KEYSand to the imperative handler)
- Plugin:
Cursor shape changes did not reach the animated cursor until restart: Fixed.cursorShapeshas two independent consumers. The fork readsstate.vim.cursorShapeslive 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 onlysetupVimSubsystems()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-appliessetCursorShapes)
- Plugin:
: Resolved, and it was never a regression — it failed identically onspike-mutable-array-destroyfailed on Windows CIf1b5626, before the #181 work. After clearing a mutable extension array and callingworkspace.updateOptions(), the spike’sViewPluginwas destroyed but itskeydownobserver still fired, on Windows only. Two rounds of diagnostics answered it: the observer fired fromembedded: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, soworkspace.updateOptions()never reconfigures them; the spike was asserting a guarantee thatregisterEditorExtensiondoes 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-editorin the document, which on Windows could be an embedded one. CodeMirror was exonerated along the way: in 6.38.6ensureHandlers()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 callsworkspace.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.tsasserts 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 onrunning, and that frame then parked the loop, discarding it. The blink’s dark half is 600 ms and the warm gear also ticks every 600 ms, so a parked loop can land on the dark half of every blink and draw nothing indefinitely; a scroll now counts as movement for blink purposes and shows the cursor solid, as it does in Neovim. And the scroll listener deduplicated againstcachedScrollTop, which stops advancing while the caret is off-pane, so scrolling back to exactly the last resolved offset looked like no change and skipped the wake entirely. (#181)- Plugin:
src/vim/animated-cursor/manager.ts(wakeRequested),src/vim/animated-cursor/controller.ts(lastSeenScrollTop/Left,positionRetryUntil, blink reset on scroll)
- Plugin:
Teardown resurrected the animated-cursor manager: Fixed.teardownVimSubsystems()destroys the manager, but CodeMirror only destroys the controllers on the laterworkspace.updateOptions(), soCursorController.destroy()ran after the manager was gone and calledgetAnimatedCursorManager()— 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 fromregister()), so the effect was a leaked object rather than a visible fault.destroy()now usespeekAnimatedCursorManager(), 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())
- Plugin:
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,ywaiting for motion) is now detected viavim.inputState.operatorwith per-frame shape polling.: Fixed. All 8 options available via vimrc (vim.opt.smoothcursor/ vimrcset smoothcursorset 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:
-
Stale normal-mode key prefix: Typing
gbuffers it ininputState.keyBufferas 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’slastInsertModeKeyTimer), and no blur/focus handler existed. On refocus, the next key combined with the stalegto produce an invalid sequence (gG,gj, etc.), which was silently swallowed. Fix: blur handler oncontentDOMcallsclearInputState()on focus loss; pane-switch handler in the plugin provides belt-and-suspenders coverage. -
Global keymap corruption via
unmap(): The fork’sdefaultKeymapis a global singleton shared across all editors.unmap()usedsplice()to remove entries, including built-in defaults likeggorj. During plugin lifecycle churn (enable/disable/reload),unregisterAll()calledunmap()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 noresetKeymap(). Fix: defaults tagged with_isDefault,unmap()skips them,resetKeymap()restores from frozen snapshot,mapclear()uses flag-based partitioning. -
Incomplete
leaveVimMode()cleanup: When an editor was destroyed while in insert mode, thechangeandkeydownlisteners registered byenterInsertMode()were not removed (onlyexitInsertMode()removes them, andleaveVimMode()didn’t call it). The globallastInsertModeKeyTimercould also fire against a destroyed editor. Fix:leaveVimMode()now manually removes insert-mode listeners, clears the timer, clearsvirtualPrompt, and resetsinputState. -
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:_commandGenerationcounter 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.
- Stale jumpList markers after document switch: The global jumpList (
vimGlobalState.jumpList) storesMarkerobjects 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()calledcurMark.find()→posFromIndex(this.offset)→doc.lineAt(offset)with no bounds check, throwingRangeError. The exception bubbled throughprocessMotion→processCommand→ thecm.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):posFromIndexclamps offset to[0, doc.length]before callingdoc.lineAt(), mirroring the bounds checking already present inindexFromPosMarker.find()catches exceptions and returnsnull— all callers (jumpList.add,jumpList.move,jumpList.find) already handlenullgracefullyMarker.update()catchesRangeErrorfrommapPos()when the marker offset exceeds the changeset’s starting document length, settingoffset = null- Plugin:
reloadFeatures()now callsvim.resetKeymap()to matchonload()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:
BlockCursorPlugin.update()now includesupdate.focusChangedin its redraw trigger, ensuring the cursor re-measures on focus transitions.- On focus gain, a deferred
requestAnimationFrameschedules a secondrequestMeasurethat 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):newLineAndEnterInsertModenow scans past----delimited frontmatter to find the first editable line and usesinsertAt.line <= firstEditableas the boundary check. The insertion point uses{ line: insertAt.line, ch: 0 }instead of hardcodedcm.firstLine(). This fixesOon all line types (plain text, headings, etc.). - Plugin (
open-line.ts): the smart list continuation override addsfirstEditableLine()with the same frontmatter scan, changing the boundary check tocurLine <= firstEditableLine(cm). This fixesOon 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)
set insertmodeescape=jk) not workingStatus: 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:
-
Wrong event source: The handler listened to
vim-keypressevents 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-keypressonly fires for keys that codemirror-vim processes as vim commands (e.g.,<Esc>, mapped sequences). The handler never saw insert-mode character keystrokes. -
Option value not retrievable: The
insertmodeescapeoption’sdefineOptioncallback did not store the value forgetOption()retrieval. WhengetOption('insertmodeescape')was called, it returnedundefined(the callback returned nothing on query), so the handler’s escape sequence check always short-circuited atescapeSeq.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)
mapCommandEasyMotion 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(notDecoration.replace) — formatting marks are real text nodes with full width in the DOM - With the filter disabled,
h/lmovement 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: — Investigation (spike27) found that ci* in Live Previewci* 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:
-
Double highlight: The plugin’s custom
linewiseVisualHighlightdecoration (full-line highlight viaDecoration.line) and the native CM6::selectionCSS rendered simultaneously. The native::selectionwas 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-vimVisualLineclass toggle and extending the::selectionsuppression to include visual-line mode. Charwise and blockwise visual modes remain unaffected. -
Cursor displacement over collapsed markup: Navigating with
j/kon lines containing collapsed markup ([[wikilinks]],[text](url)) caused Obsidian to uncollapse the hidden content, reflowing the line. This happened becauseupdateCmSelectionset a spanning CM6EditorSelectionrange across the full line content, and Obsidian’s Live Preview detects selection overlap withDecoration.replaceranges and reveals them (this is Obsidian plugin-level behavior, not CM6 core). Fixed by setting a cursor-only CM6 selection in visual-line mode — thelinewiseVisualHighlightViewPlugin provides the visual highlight independently fromvim.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):
-
Fork-side (keyboard events): When a key is NOT handled by vim in visual-line mode,
handleKeyin the fork’sindex.tstemporarily 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. -
Plugin-side (all invocation paths):
src/vim/visual-line-command-fix.tsuses three complementary patches. First,app.commands.executeCommandis wrapped viaaround()— 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 onwindow), toolbar buttons, and programmaticexecuteCommandByIdcalls (which delegates toexecuteCommandinternally). Second, every command’scheckCallbackis individually wrapped — but only the executing path (checkCallback(false)) expands the selection; the checking path (checkCallback(true)) is passed through without selection expansion because theVisualLineSomethingSelectedPatch(layer 3) already ensuressomethingSelected(),getCursor(), andlistSelections()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) -
Plugin-side (editor API patching): The
VisualLineSomethingSelectedPatchCM6 ViewPlugin patches five methods on Obsidian’sEditorobject:somethingSelected()returnstruewhen 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 viaVim.handleKey(cm, '<Esc>');getCursor('from'/'to')returns the visual-line range boundaries instead of the collapsed cursor position; andlistSelections()returns the visual-line range as a proper anchor/head selection. The cursor and selection patches are essential because Note Composer usesgetCursor('from')/getCursor('to')(notreplaceSelection()) 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. ApendingVisualLineSelWeakMap (per EditorView, 30 s TTL) snapshots the visual-line range beforewithExpandedSelectionexpands the CM6 selection, surviving async modal flows wherehandleExternalSelectionexits visual mode afterrestoreCursorOnlycollapses the selection. The fallback chain is: active vim visual-line state →lastVisualLineSel(set bygetSelection()) →pendingVisualLineSel(set bywithExpandedSelection). (#138, #157) -
Plugin-side (ex command path): The
:obcommand(and:ob) ex command handler insrc/workspace/commands.tsrestores the CM6 selection from the visual range before callingexecuteCommandById(). The fork’s_processCommand()exits visual mode before invoking ex command handlers, butparseInput_()captures the visual range intoparams.selectionLine/params.selectionLineEndbeforehand. For theexmapindirection path (where a user-defined ex command chains to:obcommandviavim.handleEx), the inner_processCommandno longer has visual mode context, so the handler falls back to the'</'>vim marks (which persist afterexitVisualMode). This covers the common vimrc patternexmap togglebullets obcommand editor:toggle-bullet-list+vmap <leader>b :togglebullets. (#161)That line-only restore was not enough for a charwise selection. A
keyToKeymapping such asvim.keymap.set("v", "<C-n>", ":obcommand <id><CR>")feeds:to the prompt, which the fork prefills with'<,'>, so the dispatcher receives'<,'>obcommand <id>— measuredselectionLine === selectionLineEnd === 0for a selection inside one line, which theselectionLine !== selectionLineEndguard 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'</'>andlastSelection.visualLine/visualBlock, which surviveexitVisualModeand carry columns. A typed numeric or%range still expands linewise;'<,'>and a bare:obcommanddo 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:
-
exitVisualModecursor clipping (src/vim.js):exitVisualMode()calledclipCursorToContent()whilevim.visualModewas stilltrue. In visual mode,clipCursorToContentallowsch = text.length(the linebreak position). After clearingvim.visualModeon the next line, the cursor was already set one position past the last character. Reproducible as:vlll<Esc>on “abc” —lpast 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 beforesetCursor, while preserving theupdateLastSelectioncall order. (#15) -
measureCursorEOL adjustment (src/block-cursor.ts): Theletter != "\n"comparison used loose equality (!=). Whenhead >= doc.length(cursor past document end), the short-circuithead < doc.length && sliceDoc(...)producedfalse, andfalse != "\n"evaluated tofalsedue to JS type coercion (both coerce to0). This caused the wrong branch to execute at document end. Fixed by producing""instead offalseand using strict inequality (!==). -
measureCursorvisual-block EOL step-back (src/block-cursor.ts): After themakeCmSelectionper-line clamping fix (issue #38), block selection heads legitimately land on newline positions (head = lineLen). Theelse if (!vim.visualLine && !vim.visualBlock)guard prevented thehead--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.visualLineguard remains because visual-line mode manages cursor positioning independently via cursor-only CM6 selection. (#41)
Visual-block A skips short lines (Fixed)
A skips short linesStatus: 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)
r off-by-one across line boundaryStatus: 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 Fixed by adding a ----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.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:
-
Multi-line jump clamp: When
moveVerticallyjumps 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). -
Tall non-wrapped line detection: When
moveVerticallystays on the same document line (lineJump === 0) but the Y coordinate change is less than half ofdefaultLineHeight, the cursor is “stuck” on a tall non-wrapped line — headings with large font size and/or line-height produce line blocks taller thandefaultLineHeight, causingmoveVerticallyto take multiple steps through the block even though the text doesn’t wrap. The fix detects this viacoordsAtPoscomparison 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. -
Column 0 fallback: When
moveVerticallycorrectly crosses one line but drops the cursor at column 0 despite a non-zero goalColumn,posAtCoordsresolves 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:
enterInsertModepreserveswasInVisualBlockbeforeexitVisualModeclearsvim.visualBlock, somultiSelectHandleKeyroutes subsequent insert-mode keys correctly through CM6’s native multi-selection text input.selectForInsertskips short lines instead of clipping the cursor to the line end. Lines shorter than the block column are left unchanged, matching Neovim.operators.changeblock visual path usescm.replaceSelections()to delete the block selection before entering insert mode at the block’s left column. Handles bothc(change block) andC(change to EOL viaapplyOperator’s linewise head extension).exitInsertModeusesblockInsertLeftto position the cursor at the block’s original left column instead of the standardch - 1. This fixesAcursor placement after<Esc>.makeCmSelectionzero-width block fix changesfromCh < toChtofromCh <= toChso that zero-width blocks (fromCh === toCh) correctly include the character at the cursor position instead of creating a backwards range.repeatInsertModeChangescursor positioning usesblockInsertLeft(stored onlastInsertModeChanges) for the final cursor position after dot-repeat, instead of a hardcoded+1offset.
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)
:sort cursor positioningStatus: 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)
CTRL-V $ d cursor overshootStatus: 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.