# Known limitations This document tracks known limitations, architectural constraints, and intentionally deferred features. For previously fixed issues, see [Resolved Issues](#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 ``/`` 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 `` abbreviations, so substitutions such as `:%s/marks/x/` remain substitutions. Dispatch payloads carry a general count and argument field rather than only an action id. Bridge refresh and disconnect remove generated mappings, commands, abbreviations, and notification listeners before reinstalling. M5 structural motions and Markdown text objects are Class A′ buffer-text behavior and do not cross the Obsidian feature bridge. The bundled companion installs buffer-local mappings backed by Neovim's bundled `markdown` and `markdown_inline` treesitter parsers and removes them during companion teardown. M5b covers emphasis, inline code, math, strikethrough, links and wikilinks, fenced code blocks, nested blockquotes, callouts, HTML tags, table cells, and table rows in operator-pending and visual modes. Operators execute over an explicit bounded visual range rather than a cursor-moving callback. Neovim's native `it`/`at` supplies tag matching, with the count consumed once to match the fork's custom object. Highlight (`i=`/`a=`) remains unavailable under RPC because Neovim's bundled Markdown grammar does not expose `==...==` as a syntax node; the companion does not fake a treesitter range with delimiter scanning. The mirrored buffer receives the plugin's `textwidth`; native `gq` and `gw` use Neovim's stock Markdown ftplugin rather than a ported wrapping implementation. ### ~~Intermittent renderer crash when disconnecting the RPC backend~~ (Fixed) **Status: fixed, and the cause was not what this entry described.** The crash was a leaked `web-tree-sitter` `TreeCursor`. `getAllNodesOfType` in `src/treesitter/js-api.ts` allocated one per structural motion and never called `delete()`, so it stayed in `web-tree-sitter`'s `FinalizationRegistry` — which registers a cursor under its **tree's** pointer, not its own. The CM6 bridge frees that tree on the next re-parse, and GC then fired the finalizer against a dangling pointer, corrupting the WASM allocator from a GC callback. That is why it needed RPC traffic (which drives the re-parses and the allocation churn) and fork editing (which leaked the cursors) in the same session. Isolating the two halves of the original fix measured the leak at **5 of 8** container runs with no nodes retained, against **0 of 6** for the retaining walk with the cursor freed. The reproducer spec that measured 24 of 46 now measures **0 segfaults in 16 runs** (p ≈ 0.004 against its own 29% post-mitigation rate). `.ast-grep/rules/treesitter-handle-leak.yml` gates the shape. The deferred-teardown mitigation described below has been **removed**. It was adopted on a measurement that is now known to have been confounded — both of its arms contained the leak. With the leak fixed, the reproducer measures 0 segfaults in 16 runs in all three teardown shapes: the 3-second deferral, the original synchronous `qa!`-and-wait that used to crash 24 of 46, and the immediate non-blocking `SIGTERM` that now ships. 48 runs, 0 segfaults. Teardown keeps the non-blocking shape on its own merits — disconnect returns immediately instead of blocking for up to four seconds — but the 3-second delay that existed only as a crash mitigation is gone. The original description follows, since the reasoning it records is what the evidence above corrects. Disabling the Neovim backend can crash Obsidian's renderer process. It is a native SIGSEGV — a read of an unmapped page through what looks like a corrupted V8 compressed pointer — so it appears as Obsidian's window disappearing or reloading, with no JavaScript error. It requires RPC traffic **and** a disconnect in the same session. Measured in a Linux CI container: 216 tests with the backend off produced none, 432 traffic-free connect/disconnect cycles produced none, and 2,400 requests without a disconnect produced none, while a spec performing 14 connect-traffic-disconnect cycles crashed 24 of 46 runs. Stubbing the extmark, float, buffer-line, and cursor handlers did not change the rate, so it is not caused by processing Neovim's output. Removing the synchronous `qa!`-and-wait from teardown reduces it about threefold (7 of 38 runs versus 24 of 46, Fisher p = 0.002) and is shipped, but a residual path remains: retaining the child process indefinitely still crashed 3 of 8 runs. Memory, JS heap growth, DOM growth, msgpack recursion depth, Electron version, and every container security and namespace setting are all excluded by measurement. **Tree-sitter WASM handle lifetime was previously listed here as excluded. That was wrong**, and it is the root cause recorded above. The arm that appeared to exclude it neutralised this repository's `delete()` calls but not `web-tree-sitter`'s `FinalizationRegistry`, so it never achieved "nothing freed" and never excluded anything. Electron's own guidance is that a renderer should not own a crash-prone child process — `UtilityProcess` exists for exactly this, and spawning subprocesses is documented as work to delegate to the main process. An Obsidian plugin has no access to either, so the backend must spawn Neovim from the renderer. Practical impact is narrower than the CI rate suggests. Building the workload up one ingredient at a time showed that RPC work alone does not crash: 40 note switches with editing, 40 structural-motion batches (`]h`, `d]l`, `gqG`), 1,200 requests, and even 14 connect/disconnect cycles each measured **0 segfaults in 8 runs**. It only reproduces when editing through the **bundled fork** is interleaved with RPC work in the same session, which measured 3 of 8. That is what a parity spec does -- alternating the two engines fourteen times per file is its purpose -- and what a person does not. A session that enables the backend and works stays on the measured-clean side. The risky pattern is disabling the backend, editing with the bundled fork, re-enabling it, and repeating. Enabling the backend is opt-in and desktop-only. There is no in-process recovery: this is a renderer-process SIGSEGV, so the plugin's own code dies with the window and nothing of ours runs afterwards. Electron's answer is `UtilityProcess`, which a plugin cannot reach. What the plugin does instead is leave a breadcrumb — a marker written before a real connect or disconnect and cleared once it settles — so a restart that finds it still set reports that the renderer died mid-switch rather than leaving the crash unexplained. Notes are unaffected: the mirror is `acwrite` and Obsidian owns the file. ### ~~Neovim popup-menu completion is not displayed in RPC mode~~ (Fixed) The attached UI requests `ext_messages`, `ext_cmdline`, and `ext_popupmenu`. M8a routes messages, M8b renders the external command line, and M8c renders popup-menu items, selection updates, and teardown. `grid=-1` completion is anchored to the command line with byte-position conversion; insert completion uses reported grid cells and CM6 metrics. Grid drawing events remain intentionally discarded. - `vim.ui.select()` and `vim.ui.input()` now display their generic Neovim prompts, accept typed responses, invoke their callbacks, and support `` cancellation. - Typing `:`, `/`, or `?` displays Neovim's command line and echoed input. With `wildoptions=pum`, command-line completion candidates and selection are visible; insert-mode completion is positioned by its grid coordinates. Commands that the plugin issues over RPC are unaffected, because they never touch the command line. Message severity is derived from Neovim's message `kind`, which does not carry `vim.notify()`'s log level. Measured against Neovim 0.12.5: `vim.notify(msg, ERROR)` arrives as `echoerr` and is styled as an error, but `vim.notify(msg, WARN)` arrives as `echomsg` — indistinguishable from `INFO` — and is therefore shown as an informational Notice. Warning styling applies to Neovim's own `wmsg` warnings. Recovering the level would require reading the message's highlight attribute rather than its kind. ### Fold persistence is unavailable in RPC mode Neovim owns fold state while the RPC backend is connected. The plugin therefore does not restore persisted CM6 fold offsets into Neovim: doing so would introduce a second fold authority and stale offsets after edits. Fold persistence continues to work in bundled-fork and built-in Vim modes. ### Uppercase cross-file mark motions are not bridged in RPC mode Lowercase within-buffer mark motions such as `'a` and `` `a `` remain Neovim-native. Uppercase marks `A`–`Z` are cross-file locations owned by the plugin, but the RPC backend reuses and renames one Neovim buffer for every active note, so Neovim's global mark system cannot preserve their cross-note identity. Batch 3 bridges `:marks` and `:delmarks` only. Motions to uppercase marks remain deferred until a later host-motion adapter is added. The mirror's buffer-local `buftype=acwrite` prevents Neovim from writing the named vault file itself. Its buffer-scoped `BufWriteCmd` notifies the host, which invokes Obsidian's `editor:save-file` command after line-event synchronization has made CM6 current, then clears Neovim's `modified` flag. A buffer-scoped `BufReadCmd` makes `:e` and `:e!` re-seed from the current Obsidian document with line-event echo suppressed, so a stale disk copy cannot flow back through Neovim and overwrite the editor. With **Neovim configuration path** empty, the backend runs the supplied Neovim binary and the user's normal Neovim configuration as arbitrary code. This production default is deliberate. A configured absolute path instead starts Neovim under `--clean`, prepends that file's directory to `runtimepath`, and loads only that `init.lua` with `-u`; this supports a smaller Obsidian-specific setup and avoids terminal-only plugins. Either configuration may use LuaJIT FFI to load native libraries and may read or write files outside the vault. No sandbox is provided. Vim Motions never downloads or installs Neovim itself. It can ask Neovim to install or update the plugins it writes configuration for, through Neovim's own `vim.pack`, into Neovim's data directory — but only on an explicit button press that first shows what will be fetched and what will be written. That is an explicitly user-requested install, which the Developer Policies allow; the clause about installing dependencies targets a plugin pulling in what it needs unasked. The fengari-only `pluginAutoFetch` path remains disconnected from this runtime, which is a separate boundary and still enforced by `test/unit/rpc/plugin-autofetch-boundary.test.ts`. `buildNeovimSpawnArgs()` in `src/rpc/neovim-connection.ts` is the whole argv surface, and the only filesystem path it can carry is the user's configured `neovimConfigPath`. `test/unit/rpc/plugin-autofetch-boundary.test.ts` asserts the complete argv, so any added `runtimepath` entry fails it, and separately asserts that no file under `src/rpc/` imports the fetch or store modules or names their on-disk paths. This is risk R-5 in the design plan, held by a test rather than by an argument. ### The visual selection is a decoration, not an Obsidian selection Neovim's visual selection is rendered as a CM6 decoration while CM6's own selection stays the caret Neovim reports. Mirroring it into `EditorSelection` was measured and rejected: with a non-empty selection in the editor, Obsidian consumes the Escape keydown before the RPC delegation listener on `contentDOM` sees it, so visual mode could be entered and never left. Neovim stayed in `v` across ``, clearing the DOM range did not help, and disabling the mirroring restored `n`. The consequence is that Obsidian features which read the editor's selection do not see a visual selection — Neovim owns the selection semantics in this mode, as it owns text and cursor. `test/specs/rpc-visual-selection.e2e.ts` pins both halves, so an attempt to "simplify" the decoration into a real selection fails on the caret assertion and on the Escape assertion rather than silently reintroducing the defect. Blockwise selections become one range per row and their columns are byte offsets rather than display cells, so a block spanning rows of differing width is approximate in the same way as the rest of the bridge's terminal-cell mapping. ### ~~Per-mode cursor shape does not follow Neovim unless the animated cursor is on~~ (Fixed) The fork now exposes `setExternalCursorMode()`, which `setExternalVimMode()` forwards to, so the cursor follows the backend's mode with **Animated cursor** off. Two constraints on that export are load-bearing and were established by measurement, not preference: - **It must not write `cm.state.vim`.** That state is shared with the status bar and the mode tracker. - **It must not redraw with a transaction.** `refreshExternalMode()` uses `requestMeasure`, which is a measurement pass; a dispatched transaction disturbs the RPC composition input. An earlier host-side attempt violated both and turned `rpc-ime.e2e.ts` and `rpc-lifecycle.e2e.ts` from 5 and 13 passing into 3 and 2 failures. Those two specs are the regression gate for any further cursor work, and neither is obviously cursor-related. Two facts worth keeping for anyone touching this: the insert cursor is the **native caret, not a fat cursor** — `measureCursor()` computes `showCursor = !insertMode || overwrite || shape !== 'bar'`, so a bar shape draws no element and `caret-color` is set instead, and a test must assert the absence of `.cm-fat-cursor` plus a non-transparent caret. And an external mode change produces no `ViewUpdate` of its own, so the fork keeps a registry of live `BlockCursorPlugin` instances to refresh; `update()` alone never sees it. ### Only the active Markdown editor is mirrored One Neovim buffer is created in `NeovimDocumentSync.start()` and renamed and reseeded on every `active-leaf-change`. A split pane therefore shows Obsidian's own rendering of a note that Neovim is not tracking: keys go to whichever leaf is active, and the inactive pane is an ordinary input-inert CM6 editor. This is deferred rather than pending, because it is an architectural change rather than a fix. The coupling, measured: | 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) A native LSP workflow already crosses the bridge without new bridge code, and `test/specs/rpc-lsp-capability.e2e.ts` measures it against an in-process server: server-provided completion items render in the external popup menu, `vim.lsp.buf.hover()` renders as a float, and diagnostic `virtual_text` and `underline` render as CM6 decorations carrying Neovim's own highlight groups. The mirror buffer is named with the note's absolute vault path, so a server's root resolution has a real path to work from. The declarative attach path is nonetheless fragile. `vim.lsp.enable()` attaches on `FileType` and skips any buffer whose `'buftype'` is already set. `activateDocument()` runs `filetype detect` before it applies `buftype=acwrite`, so the first activation attaches — but the mirror buffer is reused and renamed for every subsequent note, and by then `buftype` is `acwrite`, so every later `FileType` is skipped. Measured on Neovim 0.12.5: with `buftype` empty at `FileType` a client attaches, with `buftype=acwrite` none does, and `vim.lsp.start()` attaches to that same `acwrite` buffer. The spec pins both the gap and that `vim.lsp.start()` positive control. Both halves are fixed. `activateDocument()` now detaches any attached client before renaming the buffer, which closes the old document, and clears `'buftype'` so every activation looks like the first to Neovim's attach rule; `acwrite` is restored once the content is in place. A client therefore attaches on every note activation, including one enabled later in the session, and the server's document URI follows the note instead of going stale. `test/specs/rpc-lsp-capability.e2e.ts` asserts the `didClose`/`didOpen` pair by name and that a late `vim.lsp.enable()` attaches on the next activation while a bare `filetype detect` still does not — the latter is Neovim's own rule and is what the `buftype` clear exists to work around. ### ~~The decoration bridge carries three extmark fields and drops the rest~~ (Fixed) `ForwardedExtmark` now also carries `virt_lines`/`virt_lines_above`, `sign_text`/`sign_hl_group` and `line_hl_group`, so diagnostic signs, diagnostic `virtual_lines`, 0.12 code lens, gitsigns and whole-line highlighting render. `virt_lines` becomes a CM6 block widget, `line_hl_group` a line decoration, and `sign_text` reaches the existing sign gutter — which had to become multi-source, because marks and the backend recompute independently and a single effect meant whichever dispatched last erased the other. `number_hl_group` and `conceal` are still dropped: the first needs a line-number renderer the gutter does not expose, and the second would fight Obsidian's own Live Preview concealment. `test/specs/rpc-native-capability.e2e.ts` pins each case, pairing every "does not render" assertion with a same-namespace, same-position `virt_text` positive control so the absence cannot be satisfied by forwarding being broken outright. The fields decide real features: diagnostic `signs` need `sign_text`, diagnostic `virtual_lines` and 0.12 code lens need `virt_lines`, and whole-line diagnostic or debugger highlighting needs `line_hl_group`. ### Neovim plugin compatibility is decided by mechanism, not by plugin Plugins run inside the user's own Neovim, so loading is never the question. The bridge transports buffer coordinates and never reconstructs Neovim's screen grid, which is attached only as a redraw clock. Anything a plugin expresses as persistent extmarks, virtual text, or floating windows crosses; anything it expresses in screen cells cannot. `screenpos()`, `nvim_win_text_height()`, and the rest of the screen-cell query class are **unbridgeable by construction**, not deferred. Obsidian renders proportional Markdown typography, so there is no stable cell grid to answer such a query with, and D2 rules out grid reconstruction. `matchadd()` is unreachable for a different reason — matches are window-local and are not extmarks, so no buffer extmark query returns them. Ephemeral extmarks and legacy non-extmark highlights exist only during Neovim's own redraw and are gone before any query. flash.nvim is the measured case on the bridged side: its labels, jump, and floating prompt all render, and its own all-namespace extmarks are the oracle in `rpc-decorations.e2e.ts` and `rpc-floats.e2e.ts`. That is a class result, not a certification of any particular plugin. The feature bridge is an allowlist, so it reaches only features this plugin registers. `:ob`/`:obcommand` is bridged alongside them and is the one endpoint that escapes that limit, executing any Obsidian command by ID — including commands owned by Obsidian itself or by other plugins, which an allowlist cannot enumerate. Lowercase `:ob` reaches it through the same start-of-command-line guarded abbreviation as the other lowercase commands, so `:%s/ob/…/` remains a substitution. ### Two RPC behaviours are verified by hand, not in CI Everything else in this section is covered by `test/specs/rpc-*.e2e.ts`. Two are not, because the cost of automating them exceeds what they would catch: | 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 ``. | 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 ``/``. 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 `` 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 `` mappings whose only purpose is a group label, so a which-key plugin renders a menu instead of a flat list. The leader key itself is deliberately never mapped — doing so would make it a complete binding and break every leader sequence, which is the property `leaderGroupPrefixes` is tested for. Position animation, gutters, cursor-line highlighting, and the status bar were never affected. ### The plugin's own Lua and vimrc config does not bind editor keys in RPC mode `.obsidian.init.lua` and `.obsidian.vimrc` still load while RPC is connected, but a `vim.keymap.set` or `map` defined there targets the bundled fork, and key delegation stands the fork down through `setKeyInterceptActive(true)`. Editor-context mappings from the plugin's config therefore never fire; the Neovim equivalent belongs in the user's own `init.lua`. Settings and host-rendered features are unaffected, because Obsidian still renders them. The Obsidian action mappings the feature bridge installs into Neovim are generated from the plugin's registration data, so they follow the configured leader key but not arbitrary `vim.keymap.set` remaps. ## Lua configuration **Status**: Implemented. The plugin provides a Lua 5.3 runtime via a browser-only version of fengari, absorbed into the monorepo at `src/lib/fengari/` and converted to TypeScript ESM. Configuration is loaded from `init.lua` (or `.obsidian.init.lua`) and supports `vim.keymap.set`, `vim.opt`, `vim.fn`, `vim.api`, and more. **Known limitations**: ### ~~Some `vim.opt` options not registered~~ (Fixed) **Status**: Fixed. All documented `vim.opt` options are now registered in both `KNOWN_SET_OPTIONS` and `vim.defineOption`. ([#90](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/101)) Two issues: (1) vimrc/Lua overrides were in-memory only — `saveSettings()` stripped them to preserve UI values, so they were lost on restart. CM6 gutter extensions are created at startup from persisted values, so the overrides never took effect. (2) `reloadFeatures()` never called gutter reconfiguration functions. Fixed with a `configOverrides` persistence system: after vimrc/Lua loading, override values are captured in a `configOverrides` block in `data.json`. On next startup, these are merged on top of base settings before CM6 extensions are created. Also added gutter reconfiguration calls to `reloadFeatures()` for in-session changes. The one-restart requirement originally recorded here no longer applies. Those in-session reconfiguration calls were themselves inert: `iterateEditorViews()` resolved `editor.cm.cm` — the CM5 compatibility adapter, which has no `dispatch` — so its guard rejected every leaf and the callback ran for no editor at all. Every gutter change therefore appeared to need a restart, because a restart was the only path that rebuilt the extensions. With the accessor corrected, `test/specs/gutter-vimrc-lua.e2e.ts` asserts the gutter is present in the editor DOM immediately after a Lua config load, with no restart. Additionally, 27 settings that were previously UI-only are now configurable via vimrc/Lua: `subword`, `picker`, `pickerleadermappings`, `pickermatcher`, `pickeromnisearch`, `pickertasks`, `pickerdataview`, `ripgrep`, `ripgreppath`, `ripgrepargs`, `grepmode`, `oil`, `oilhiddenfiles`, `oilconfirmdeletethreshold`, `oilsort`, `hinthotkey`, `undotreeposition`, `undotreeautoopen`, `imswitching`, `impreset`, `imbinarypath`, `imobtainargs`, `imswitchargs`, `imdefaultnormal`, `imrestorebehavior`, `imdefaultinsert`. ### ~~`preVimrcSettings` shallow copy bug~~ (Fixed) **Status**: Fixed. `preVimrcSettings` snapshot at line 677 now deep-copies `cursorShapes`, `modePrompts`, and `pickerKeymap`. Previously, nested objects shared references with `this.settings`, causing `Object.assign` mutations in `applySettingOverride` to leak through to `preVimrcSettings` — making `saveSettings()` accidentally persist overridden values (e.g., cursor shapes set via `set guicursor` in vimrc were permanently saved to `data.json`). ### ~~Clipboard/textwidth falsely shown as "Set by vimrc"~~ (Fixed) **Status**: Fixed. The initial settings restoration at startup used `onSettingOverride()` for `clipboard` and `textwidth`, which wrote to `vimrcOverrides` even without a vimrc file. These settings appeared as "Set by vimrc" in the Settings UI and `saveSettings()` stripped them. Fixed by using direct side-effect calls (`setClipboardOption`, `setTextwidth`) that bypass the override pathway. ### Expr mapping limitations - **String expr mappings are not supported** — `vim.keymap.set('n', 'k', "v:count == 0 ? 'gk' : 'k'", { expr = true })` requires Vimscript expression evaluation which is not available. Use a Lua function callback instead. - **Async expr callbacks are not supported** — Expr callbacks run synchronously. Calling async APIs (e.g., `vim.ob.fs.read`) inside an expr callback will error. The callback must return a string immediately. - **Expr results do not compose with pending operators** — When an expr mapping runs during operator-pending mode (e.g., `d` followed by an expr-mapped key), the returned keys execute independently. The pending operator state is cleared before the callback runs. `vim.v.operator` is still readable inside the callback. - **Count is not auto-forwarded to expr results** — Typing `3K` where `K` is expr-mapped and returns `'j'` will execute `j` once, not three times. Include the count in the returned keys: `return vim.v.count1 .. 'j'`. ### vim.v limitations - **`vim.v` values in async callbacks are only reliable before the first yield** — In async (non-expr) callbacks, `vim.v.count`, `vim.v.register`, and `vim.v.operator` reflect the values at callback start. After an async yield (`vim.ob.fs.read`, etc.), another callback may have overwritten these values. Read them into local variables at the start of your callback. - **`vim.v.count` returns 0 outside callback context** — Reading `vim.v.count` from a timer, autocmd, or `vim.schedule` callback returns 0, not the count from the most recent command. ### vim.v deferred variables The following `vim.v` variables are registered in the API and return default values, but are not yet populated by their respective subsystems. They will become active when the corresponding features gain Lua evaluation support: - **`vim.v.foldstart` / `vim.v.foldend` / `vim.v.foldlevel` / `vim.v.folddashes`** — fold text evaluation context variables. Currently return 0/`''`. Will be populated when a custom `foldtext` Lua callback is added (deferred to statuscolumn/foldtext v2). Note: `foldlevel` calculation requires counting enclosing folds, which is not currently tracked by CM6's flat fold decoration system. - **`vim.v.lnum` / `vim.v.relnum` / `vim.v.virtnum`** — statuscolumn per-line rendering context variables. Currently return 0. Will be populated when the statuscolumn format string gains Lua expression evaluation (deferred to statuscolumn v2). The injection point (`lineMarker()` in `statuscolumn.ts`) is identified; the variables have no consumer until Lua expressions are supported in the format string. - **`vim.v.char`** — character typed during `InsertCharPre` autocmd. Currently writable but never set by the plugin. Will be populated when `InsertCharPre` is added to the supported autocmd events (requires a fork hook into insert-mode character input). Not in the current 19-event list. - ~~**`vim.v.insertmode`**~~: Fixed. Returns `'i'` for insert mode, `'r'` for replace mode (`R`), `'v'` for virtual replace mode (`gR`), and `''` in normal/visual modes. Available in keymap function callbacks. Autocmd callbacks default to `''` (no adapter context available). ### Module snapshot and `require()` `require()` searches a `lua/` directory beside the configured `init.lua` first, then `lua/` at the vault root. Every `.lua` file under those roots is read into memory when the configuration loads, and `require()` resolves from that snapshot. This is what lets a lazy `require` inside a keymap callback work: those callbacks cannot wait for a file read. Asynchronous reads remain the fallback, but only for callers that can wait (top-level configuration, autocommands, timers). - **A `lua/` directory beside a configuration outside the vault is desktop only** — it is read through the filesystem rather than the vault adapter. On mobile it contributes nothing, which matches the out-of-vault configuration file itself being unreadable there. A configuration inside the vault has no such restriction. - **Files created or edited after the configuration loads need a reload** — the snapshot is a point-in-time copy. Requiring a module added since reports `module '' not present in the configuration snapshot`, naming every path tried across both roots, rather than a generic "not found" that would be indistinguishable from a typo. Saving your main configuration file reloads it, as does the **Vim Motions: Reload configuration** command. There is no live watcher on `lua/`. - **The snapshot refreshes on configuration reload and after `vim.plugins.add()`** — a plugin fetch rebuilds it before your Lua resumes, so a freshly fetched plugin is immediately requirable. - **Four resource limits apply, and breaches are reported rather than silent** — 512 KiB per file, 16 MiB total, 2,048 files, and 32 directory levels. Anything skipped is named in the developer console with its reason, because a file dropped for exceeding a budget would otherwise present as a missing module. Directories beginning with `.` (including the plugin fetcher's `lua/.staging/`) are not walked. - **A module absent from the snapshot is unreachable from synchronous callers even if it exists on disk** — this is the same reload boundary, seen from the other side. From a coroutine caller the asynchronous read still finds it. ### Lua API limitations - ~~**`nvim_buf_get_mark` returns character offsets rather than byte offsets**~~ — Fixed for mark reads: line 1 / UTF-8 byte column 0, `{0,0}` when unset, and `v:maxcol` for a linewise end. Mark setters remain a separate deferred seam. - **`nvim_buf_get_option`/`nvim_buf_set_option`/`nvim_get_option`/`nvim_set_option` are deprecated** in Neovim (replaced by `vim.bo`/`vim.o`). Provided as compatibility aliases. - **`nvim_feedkeys` only supports mode flags `'n'` (noremap) and `'m'`/`''` (remap)**. Other flags (`'t'`, `'i'`, `'x'`, `'!'`) are ignored with a console warning. - **`nvim_echo` maps to Obsidian's `Notice` API** — highlight groups in chunks are ignored (plain text only). - **All buffer/window/tabpage handles must be `0` (current)**. Multi-buffer/multi-window operations are not supported. - ~~**`nvim_replace_termcodes` is an identity function**~~: Fixed. Supported notation now produces Neovim key bytes, and `nvim_feedkeys` decodes them into the fork's notation at the boundary. Unknown notation stays literal. `from_part` is ignored; unlike Neovim, `` expands with `do_lt = true` even when `special = false`. - **`vim.on_key` observes pre-mapping input**, unlike Neovim's post-mapping hook. Mapped expansions and programmatic `feedkeys` are not separately observed; callback return values cannot discard keys; both callback arguments carry the same physical input. Closing this gap requires changing key processing itself. Registration, replacement, removal, namespace allocation, and teardown are implemented. - **`vim.iter` has three extensions** — `rpop`, `count`, and `size` are not present in Neovim 0.12. `rpop` aliases tail-removing `pop`; `count` drains the iterator; `size` does not consume and requires a list source, raising on function sources (including map-table and callable-table pipelines). - **Global option compatibility values do not implement Neovim features** — `vim.o`/`vim.go` read engine values first, then a shared shadow store, then defaults for `eventignore`, `selection`, `cmdheight`, `columns`, `lines`, `cpo`, and theme-derived `background`, otherwise `nil`. `columns = 80` and `lines = 24` are fallback values, not measured dimensions. `operatorfunc` now shares real read/write handling across `vim.opt`, `vim.o`, `vim.go`, and the global option API functions; `g@` execution requires the bundled fork's callback support. - **Current-window compatibility only** — `nvim_win_call(0, fn)` and `nvim_buf_call(0, fn)` call directly and propagate return values/errors without switching context. `nvim_win_get_config(0)` reports a non-floating window (`relative = ''`). `vim.fn.getwininfo()` returns one active-editor record with measured CM6 geometry and 1-based inclusive `topline`/`botline`, or an empty list for a non-zero handle or no active editor. - **Synthetic window identity and dimensions** — `nvim_win_is_valid` accepts only handle 0 as valid; width/height are CM6 viewport cells (0 without an editor), position is `{0,0}`, number is 1. `win_getid()` returns 0, also the failure value for invalid ordinals; `winnr()`/`winnr('$')` return 1 and `winnr('#')` returns 0. These do not add multi-window handles. - **D4: interior-byte cursor writes deliberately differ from Neovim** — `nvim_win_set_cursor` accepts byte columns but normalizes a position inside a UTF-8 character to that character's first byte. Neovim preserves interior bytes; a UTF-16 host cannot represent them. Past-EOL clamping is native and unchanged. Do not describe this normalization as parity. - **D5: text writes normalize interior bytes — a deviation, not parity** — `nvim_buf_set_text` normalizes an interior start column down to the character's first byte and an interior exclusive end column up to the boundary after the character. In contrast, `nvim_buf_get_text` honors interior bytes exactly by slicing the UTF-8 encoding. This asymmetry is intentional: fengari holds Lua strings as `Uint8Array`, so a split character is representable on the Lua side, while the host document is a JS UTF-16 string in which invalid UTF-8 has no representation. Reads clamp past-EOL columns; writes reject them. Empty ranges at exact character boundaries still insert. - **D6: `curswant` reports the fork's partial goal state — a deviation, not parity** — `getcurpos()` returns five elements; the fifth reads sticky goal state from `vim.lastHPos` (not pixel-valued `lastHSPos`). A stored goal survives shorter lines and is converted to 1-based; the fork's `Infinity` after `$` maps to `2147483647`. When `lastHPos` is `-1`, there is no pending goal, so the fallback uses the cursor's display position: first cell of a wide character, last cell of a tab. This host-state mapping is not full Neovim goal-state parity. - **Extmark interior-byte normalization is a deviation** — Neovim preserves interior byte columns; CM6's UTF-16 offsets cannot retain those byte remainders. Start columns normalize down and exclusive `end_col` up, following D5; getters return normalized byte columns, not the original interior bytes. Both endpoints accept `0..bytelen` inclusive and reject out-of-range columns. For example, an end inside a multibyte character expands the highlight rather than collapsing it to an empty range. This is not parity. - ~~**Text, legacy-position and extmark columns use host units**~~ — Fixed through the shared adapter for `nvim_buf_get_text`/`nvim_buf_set_text`, `getpos`/`getcurpos`/`setpos`, and extmark setters/getters, subject to the deviations above. Extmark `details` now serialize as Lua tables with byte `end_col` and `virt_text` chunk pairs. - **Only enumerated coordinate APIs have the byte contract** — `nvim_buf_set_mark`, `cursor`, view save/restore, `wincol`, `searchpos`, and JS-backed `strlen`/`strpart`/`stridx`/`strridx` remain deferred. D1 governs the manifest's 23 enumerated APIs, not the whole shim. - **`vim.bo.commentstring` defaults to `%% %s %%`** (Obsidian-native comment syntax) for all buffers. Treesitter-contextual commentstring (e.g., `// %s` inside a JS code block) is not implemented. - **`vim.is_callable` does not detect callable tables** with `__call` metamethods in the fengari sandbox. Only functions return `true`. (Note: `rawget`/`rawset`/`rawequal` are now available in the sandbox for Neovim compatibility, but `vim.is_callable` still uses a function-type check rather than `rawget` on the metatable.) - **`lockmarks` ex command modifier is silently stripped** in `vim.cmd()`. Marks are not preserved during buffer edits performed via `lockmarks`. - **`vim.plugins.add()` supports automatic fetching** from GitHub. Users can specify a repository (e.g., `owner/repo`) and optional branch/tag/commit. Archives are downloaded as tarballs and extracted to `lua/`. Requires `pluginAutoFetch` setting to be enabled. - **`require()` supports `init.lua` fallback** — matches Neovim's module resolution by trying `lua/name/init.lua` if `lua/name.lua` is missing. - **`nvim_create_namespace` returns unique integer IDs** per namespace name (matching Neovim). Previously returned `0` for all namespaces. - **Extmark limitations**: `nvim_buf_set_extmark` supports `virt_text`, `virt_text_pos` (`"overlay"`, `"eol"`, `"inline"`), `hl_group`, `end_row`, `end_col`, `hl_eol`, `priority`, and `id`. Columns are bytes with the interior-byte normalization deviation above. `sign_text`, `conceal`, `virt_lines`, and `line_hl_group` remain deferred; buffer handle must be `0`. Priority sorting is implemented but does not guarantee visual precedence over native CM6 decorations. - **`nvim_buf_get_text` returns lines as a table** (like `nvim_buf_get_lines`) with the range extracted. Buffer handle must be `0`. - **`nvim_get_option_value`/`nvim_set_option_value`** support `scope = "global"` and `scope = "local"`. Both map to the same option store (Obsidian has no true per-buffer option separation except for options explicitly handled by `vim.bo`). - **`vim.fn.getcharstr()`/`vim.fn.getchar()`** are async — they yield the Lua coroutine and resume when a key is pressed. A shared broker owns a single capture-phase listener and intercept lease across all waiters, so one keypress resolves exactly one waiter, in call order. Modifier-only keys (Shift, Ctrl, Alt, Meta) are ignored. Cannot be used in expr callbacks or snippet nodes. **They remain subject to the coroutine runner's 10-second await bound**: waiting longer fails the call with `async operation timed out`. The listener and intercept lease are released when that happens, so a timeout no longer costs the user a keystroke, but a genuinely long human pause still cannot be awaited. Decoupling interactive waits from that bound is tracked in `.sisyphus/plans/async-keymap-callbacks.md`. - **`vim.fn.input()`** is async — opens an Obsidian modal with a text input field and yields until the user submits or cancels. Returns `nil` on cancel (Neovim returns empty string). Cannot be used in expr callbacks or snippet nodes. ## Treesitter integration (`vim.treesitter`) **Status**: Implemented (Phase 0–3 complete). The plugin provides a `vim.treesitter` API backed by `web-tree-sitter` (WASM). Treesitter runs as a parallel parser alongside CM6's Lezer — Lezer continues to power CM6's native highlighting, folding, and indentation while treesitter is exposed through the Lua API for Neovim plugin compatibility. Markdown, Markdown inline, and HTML grammars are bundled; Lua configuration loading awaits runtime initialization and query preloading before evaluating user code. **Implemented**: `get_parser`, `get_string_parser`, `get_node`, `get_node_text`, `get_range`, `get_node_range`, `is_in_node_range`, `is_ancestor`, `node_contains`, TSNode (31 methods), TSTree, LanguageTree (18 methods), `query.parse`, `query.get`, `query.set`, `query.get_files`, `Query:iter_captures`, `Query:iter_matches`, 8 built-in predicates with `#not-*`/`#any-*` generics, 4 built-in directives, `language.register`, `language.get_lang`, `language.get_filetypes`, `language.add`, `language.inspect`, `query.add_predicate`, `query.add_directive`. **Known limitations**: ### ~~`query.get()` does not load `.scm` files from the vault~~ (Fixed) **Status**: Fixed. `vim.treesitter.query.get(lang, query_name)` now resolves `query.set()` overrides, then `/lua/queries/{lang}/{name}.scm`, then `/lua/{plugin}/queries/{lang}/{name}.scm`, then bundled `textobjects` queries for `markdown`, `markdown_inline`, and `html`. Auto-fetched plugin queries use `lua/{owner}__{repo}/queries/`. Resolution supports `;; extends` and recursive `;; inherits:` with optional `(language)` syntax, cycle detection, lazy compilation, and cache invalidation. **Correction to the previous compatibility claim**: The missing file loader affected query-dependent integrations such as nvim-treesitter-textobjects. It did not block core mini.ai or mini.surround functionality: their Treesitter textobjects are opt-in, with `use_nvim_treesitter = false` by default. The earlier blanket claim about mini.ai was overstated even before this fix. Resolving this blocker does not establish complete compatibility with those plugins. ### Query file limitations - **Physical paths only** — `query.get_files()` returns vault-relative physical paths. Bundled queries are TypeScript string constants without file paths and are omitted. - **No live watcher** — `.scm` edits require a configuration reload. The file snapshot is preloaded before user Lua runs; successful plugin fetches refresh it before Lua resumes. - **Older cached plugins need re-fetching** — the old downloader discarded `.scm` files. A configuration reload alone does not acquire those missing files. - **Resource limits** — 128 KiB per file, 4 MiB per snapshot, 512 KiB per combined query, 64 resolved sources, and 16 inheritance levels. Exceeding a limit logs a diagnostic and skips affected content. ### Only Markdown and HTML grammars are bundled `vim.treesitter.language.add("javascript")` (or any non-bundled grammar) fails with an error. The plan called for CDN-based grammar fetching on first use, but this is not yet implemented. **Workaround**: Only `"markdown"` and `"html"` are available. Users cannot parse JavaScript, Python, or other languages embedded in code blocks. **To fix**: Implement on-demand grammar fetching from a CDN (e.g., GitHub releases of tree-sitter grammar repos). Download the `.wasm` file, cache it in the plugin's data directory via `adapter.writeBinary()`, and load it via `Language.load()`. The runtime already supports async grammar loading via the coroutine bridge. ### `#lua-match?` predicate uses ECMAScript regex The `#lua-match?` query predicate falls back to ECMAScript `RegExp` instead of Lua's `string.find` pattern matching. Most `.scm` files use `#match?` (which correctly uses ECMAScript regex), but some Neovim-specific query files use `#lua-match?` with Lua pattern syntax (e.g., `%w+` instead of `\w+`) that differs from regex. **Workaround**: Use `#match?` with ECMAScript regex syntax in custom queries. **To fix**: Implement Lua pattern → ECMAScript regex translation, or evaluate `#lua-match?` patterns through the fengari Lua `string.find` function. The latter is more correct but slower. ### Stub functions: `start()`, `stop()`, `foldexpr()`, `select()`, `inspect_tree()` These functions are present (calling them won't error) but don't perform their intended action: - **`vim.treesitter.start()` / `stop()`** — no-op. Treesitter-driven syntax highlighting via CM6 decorations is not implemented. The plugin uses Lezer-based highlighting natively. - **`vim.treesitter.foldexpr()`** — returns `"0"`. Treesitter fold computation from `folds.scm` queries is not implemented. The plugin has its own fold system. - **`vim.treesitter.select()`** — no-op. Treesitter-based structural visual selection is not implemented. - **`vim.treesitter.inspect_tree()`** — no-op. The tree inspector debug UI is not implemented. These are lower priority because the plugin provides equivalent native features (highlighting, folding) and the functions are rarely called by Neovim plugins (they're Neovim UI/editor integration points, not plugin API). ### `TSNode` handles go stale after a re-parse Neovim's contract is that a re-parse produces a _new_ tree and leaves the old one valid until its owner deletes it, so Lua written against Neovim may hold a node across an edit. This plugin deletes the old tree on re-parse, and a `TSNode` is a light userdata holding an address into WASM linear memory. A node read after its tree was replaced therefore returns whatever now occupies that address. **Measured severity**: stale data, not a crash — 80 nodes read after their tree was deleted, in each of 6 runs, with 0 segfaults. `tree.delete()` frees _within_ the mapped heap rather than unmapping, so the read returns plausible-looking but wrong types and ranges. **Status**: declined, not deferred. Both mechanisms that would fix it are unavailable. fengari arms its `FinalizationRegistry` only for full userdata, while nodes are light userdata on plain tables, so `__gc` never runs for them. Reference counting is not available either, because the fix would have to keep trees alive from node references, and that is a table-to-full-userdata conversion across all 31 node methods plus a fengari change — not a localized patch. Note that simply dropping the `delete()` calls is **not** a safe alternative. `web-tree-sitter` registers every handle with its own `FinalizationRegistry`, so a dropped tree is still freed — just at a GC-determined moment instead of a known one, which is strictly harder to reason about. A cursor is worse: it registers holding its _tree's_ pointer, so a dropped cursor whose tree was already deleted frees a dangling pointer from a GC callback. That is the root cause of the renderer segfault recorded in `test/flaky-inventory.md`. **Workaround**: re-acquire nodes after any edit rather than holding them across one, which is good practice against Neovim as well. ### `get_captures_at_pos()` / `get_captures_at_cursor()` return empty These functions still return empty tables. Named query loading is now available, but these helpers are not wired to evaluate a highlights query. Adding a `highlights.scm` file alone does not implement them. ### Injection support is structural but not query-driven The `LanguageTree` class supports child language trees and injection resolution, but its `loadInjectionQuery()` still compiles an empty query rather than consulting named query resolution. Loading `injections.scm` via `query.get()` does not automatically wire it into `LanguageTree`. Non-bundled language grammars remain unavailable. **To fix**: Connect `LanguageTree` injection loading to named queries. Calling `query.set()` alone does not bridge this remaining integration gap. ## Cross-note jump list **Status**: Implemented. The jump list tracks cursor positions across different notes, allowing you to navigate back and forth through your jump history using `` and ``. 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: ``/`` 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 ``/``**: The `jumpListWalk` action override and within-buffer jump delegation are covered by passing E2E tests. - **Cursor restoration uses a fixed settle delay**: `openJumpEntry()` waits a hardcoded 50 ms after opening a leaf before restoring the saved cursor position, then verifies the active view matches the target path. When the editor takes longer than that to become ready — a cold file, a very large note, slow storage, or mobile — the guard causes cursor restoration to be skipped. The failure is contained rather than incorrect: the correct note is still opened and focused, and no cursor is ever written into the wrong file; only the saved line/column is not restored. A proper fix replaces the timer with a deterministic wait on editor readiness, which needs its own regression coverage. ## Table cell vim modality **Status**: Implemented. The plugin uses Obsidian's native table editor for cell editing. `set tablewidget=native` (default) uses the native `cm-table-widget`. Cell editing uses native `TableCellEditor` instances created by Obsidian's `editTableCell()` method. Vim is injected into native cell editors via `registerEditorExtension()` propagation. Three table editing modes are supported: | `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`/`l` cross-cell navigation**: In normal mode, `h`/`l` at cell boundaries move to the adjacent cell (same row). `j`/`k` at row boundaries move to the same column in the next/previous data row (separator rows are skipped). `j` at the last data row or `k` at the header row exits the table. When the cursor is not at a cell boundary, `h`/`j`/`k`/`l` move the cursor within the cell as normal vim motions. Operator-pending (`dj`, `yl`) and visual mode motions stay within the cell. - **Register sharing**: Vim registers are shared between cell editors and the main editor via the fork's `vimGlobalState` singleton. Yank in one cell, paste in another. - **Status bar sync**: The mode tracker reads vim mode from the cell editor's CM6 instance when a cell editor is active, so the status bar reflects the cell editor's mode (insert/normal/visual). - **`ir`/`ar` table row text objects**: `ir` selects inner row content (between first and last `|`, excluding pipes), `ar` selects the entire row including pipes. Works in raw markdown mode only — inside cell editors, the content doesn't match `TABLE_RE` so these are no-ops (correct behavior). **Remaining limitations**: - ~~**Cross-cell editing via Tab exits table-nav**~~: Fixed. Tab in a cell editor now exits the cell and returns to table-nav on the next cell. Shift+Tab navigates to the previous cell. Tab/Shift+Tab also work directly in table-nav mode (equivalent to `l`/`h`). Count prefix works (`3Tab` moves 3 cells forward). - ~~**Count prefixes not supported**~~: Fixed. `3j` in table-nav mode now moves 3 rows. Digit keys are accumulated as a count prefix and consumed by the next navigation key. Count also works for `h`/`l` column navigation (`2l` moves 2 columns right). - **Visual-cell selection not supported**: Selecting multiple cells via visual mode is not implemented. - ~~**Dot-repeat for structural commands not supported**~~: Fixed (table-nav mode only). `.` in table-nav mode now repeats the last structural command (`o`, `O`, `dd`, `dc`, `J`, `K`, `H`, `L`, `I`, `A`). Count prefix works (`3.` repeats 3 times). The last structural action is cleared when entering cell edit mode, so vim's native `.` handles text edits after cell editing. `.` only works while in table-nav mode — after exiting, vim's native dot-repeat takes over. - ~~**Cross-cell word motions**~~: Fixed (normal mode only). `w`/`b`/`e`/`W`/`B`/`E`/`ge`/`gE` at cell boundaries now jump to the adjacent cell in normal mode. The `moveByWords` motion is overridden with the same cross-boundary pattern as `moveByLines`/`moveByCharacters`. When the word motion result is `null` (can't move further) or the cursor is stuck at a boundary, the override crosses to the next/previous cell via `getNextCell`. Visual mode and operator-pending mode (`dw`, `cw`) do not cross cell boundaries — each cell is a separate CM6 editor instance, so cross-cell selections and operations are not possible (same architectural constraint as visual-cell selection and cross-cell `h`/`j`/`k`/`l` in visual/operator-pending mode). - ~~**Count prefix on cross-cell motions**~~: Fixed. `3j` in a cell editor now crosses 3 cell boundaries. The `moveByLines` and `moveByDisplayLines` overrides loop `repeat` times through `getCellBelow`/`getCellAbove`, stopping at table boundaries. - ~~**Obsidian hotkeys blocked in table-nav mode**~~: Fixed. Ctrl+P, Ctrl+S, and all other Obsidian hotkeys were silently swallowed while the table-nav overlay was active. The nav scope and cell-edit scope were created with `new Scope()` (no parent), disconnecting Obsidian's global hotkey bindings from the keymap resolution chain. The keymap handler's `default` case also consumed all unhandled keys. Fixed by parenting both scopes to `app.scope`, returning `false` for unhandled keys, and removing `stopImmediatePropagation`. ([#146](https://github.com/saberzero1/motions/issues/146)) - **Under the Neovim backend, an owned table is presentational.** `tableWidgetMode: owned` now renders while RPC is connected, but the nested cell editor is deliberately inert — `contenteditable=false` and never focused — so Neovim keeps owning text, keys, IME, floats and the command line. Two costs follow. Neovim's own rendering does not appear inside the table: extmarks, flash labels, diagnostics, folds and signs all dispatch to the parent editor, whose copy of the range is block-replaced. And the active cell is not highlighted, because a block-replaced range cannot host a caret — under the bundled engine it is the focused child that keeps the parent's selection parked there, and a presentational child cannot do that. Measured with Neovim on row 5, the parent's head sits on row 3. Neovim's cursor remains authoritative and edits land on the correct row. A CM6 → Neovim cursor push would not help: CodeMirror relocates the selection straight back, and no loop guard exists for that direction. - ~~**No caret is drawn inside a cell in `tableWidgetMode: owned`**~~: Fixed in `@saberzero1/codemirror-vim@6.4.4`. The nested cell editor did draw a caret and did hold focus; the fork's `BlockCursorPlugin` was hiding it. Its `update()` set `display: none` on every `.cm-cursorLayer:not(.cm-vimCursorLayer)` found in its own `scrollDOM` — correct for its own view, wrong for a **separate `EditorView` mounted inside** that `scrollDOM`, which `querySelectorAll` reaches because a subtree query does not stop at an editor boundary. The nested editor draws its caret with plain `drawSelection()`, having been built without the vim extension. The fork now skips layers whose `closest('.cm-editor')` is not its own `view.dom`. This also explains why every measurement of that caret reported `0x0`: a hidden element has no bounding box, so each probe was measuring the consequence. Whether the parent's cursor is reliably drawn **outside** a table in this mode is a separate, still-unresolved question — a DOM query reports none while the fork's own layer counter reports one, and the instrumentation cannot yet distinguish the parent from Obsidian's transient cell editor. - **The caret inside a cell does not reflect vim mode in `tableWidgetMode: owned`.** With the caret in a table the cell shows CodeMirror's native `drawSelection()` caret — measured `1x19` in normal mode, insert mode, and back, identical in all three — rather than the fork's mode-shaped cursor. So normal and insert are indistinguishable inside a cell, and the per-mode cursor shapes (`cursorShapes`) do not apply there. The nested editor is built deliberately **without** the vim extension so that one vim state keeps owning commands, registers and dot-repeat, and the fork's `effectiveVimState` returns null without `cm.state.vim`, so that view can draw no vim cursor at all. A fork API was prototyped — a cursor plugin drawn in the nested view while reading mode from the parent's adapter — and is **not** shipped: it rendered a block cursor (`10x19`) but the shape still did not follow mode, the native 1px caret was not hidden so two carets showed, and the plugin's hand-written `CmAdapter` type does not satisfy the fork's `CodeMirror`. Closing it needs the parent's `insertMode` to be resolved correctly from the child's update cycle, which the prototype did not achieve. - **`tableWidgetMode: raw` is deprecated and will be removed.** Despite the name it does not show Markdown source: it hides Obsidian's widget with CSS while Obsidian still block-replaces the table's range, so the table renders as nothing at all. Use **Source mode** to edit a table's source. The value keeps working for now and emits a one-time notice; the legacy values that used to resolve to it (`suppressTableWidget: true`, `tablewidget=always`) now retarget to `native`, while an explicitly chosen `raw` is left alone. - **`tableWidgetMode: owned` replaces the table renderer, not Obsidian's cell editor**: parking the cursor in a table still constructs a `TableCellEditor`, and while `editMode.tableCell` holds it a write to that one cell's range does not land — writes to the header row, the separator row and the rest of the document all do. Obsidian builds it **off-document** (measured at `cm.dom.isConnected === false`, with focus still on the parent), so it is invisible and does not contest focus. Owned mode clears it from the same pass that maintains the nested editor, which restores writability; `null` is Obsidian's own resting value for that field. A nested editor of our own is mounted into the table's widget and takes focus when the cursor is inside it. A key router sends every keystroke to the parent's vim, so normal and visual mode work there (`dd`, `u`, `.`, `l`, `j`, `V`, `Vjd` all measured). Insert-mode characters cannot be routed — `handleKey` returns `undefined` for them, because the fork leaves them to CodeMirror's native input path — so they stay native to the nested editor and reach the parent as minimal single-region diffs, suppressed while an IME is composing. `owned` therefore supports normal, visual and insert mode on one vim state, one undo history and one document. Visual selections render, mirrored from the parent's range — charwise from the parent's own CodeMirror selection, linewise expanded from the fork's `vim.sel` line numbers, and visual block as the enclosing charwise span, because the mirror currently forwards only the parent's primary range. The fork already produces one range per row for visual block, and CodeMirror supports multiple ranges, so this is a gap in the mirror rather than a limit of either. Moving the cursor out of the table hands focus back to the parent editor, preserving the desired column and any count, and re-entering remounts and refocuses. **Still missing before it could become the default**, enumerated rather than implied: - **Neither renderer re-aligns a cell's contents, and neither is an HTML ``.** Idle tables now render as a marked-up grid — cell and delimiter spans carrying each column's alignment as a class — but both surfaces show the table's **source characters** in a monospace grid rather than a formatted table. That is deliberate: the cursor's editor is CodeMirror text over the padded source, so a renderer free to lay cells out independently would shift the grid the instant the caret entered the table. A column marked `---:` is therefore themeable but its text is not moved. - **The table-nav overlay's single keys are `native`-only.** `o`, `O`, `dd`, `J`, `K`, `H`, `L`, `I` and `A` carry structural meanings only where the overlay auto-activates, which is `native`. In `owned` they keep their ordinary Vim meanings. The structural operations themselves **are** available there, through the `:table*` ex commands and their `t` bindings. - ~~**Visual block renders as the enclosing charwise span**~~: Fixed. The parent carries one CM6 range per row — `` over three rows yields three ranges holding the individual cell texts — and the mirror forwarded only the primary one, so a three-row block displayed as one row. Every range is now mirrored, with `mainIndex` preserved; the nested editor also needed `EditorState.allowMultipleSelections`, without which CodeMirror keeps only the first range however many are dispatched. - **`scrolloff=100` disables horizontal scrolling inside a table in `native`** — issue [#167](https://github.com/saberzero1/motions/issues/167) item 6. `owned` no longer reproduces it: the monospace grid sets `white-space: pre`, so the nested editor does not wrap and still scrolls — measured `owned: 67` against `native: 0` on a 1274px-wide window, both zero where the table fits. Ordinary horizontal scrolling (item 5) works in both; the nested editor follows the caret. - **Obsidian widget features not reproduced**: row buttons, column buttons, the column context menu, column and row **drag-to-reorder**, **sort by column**, **mouse** multi-cell selection, and alignment-aware rendering. Four former entries are done: the **cell context menu** opens (a right-click reaches Obsidian's `editor-menu` pipeline), **keyboard** multi-cell selection renders a true rectangular block, **click-to-place-cursor** moves the parent's cursor to the clicked cell, and **malformed-table handling** is defined rather than absent — a short row yields fewer cells and a long one is truncated for layout, both leaving the document byte-identical. Measured from the widget's DOM classes and its 63 prototype methods; an earlier count of "nine" omitted drag-to-reorder, sort-by-column and the column menu. - **Automatic realignment is lost.** Obsidian's native table editor realigns a table's columns when it commits a cell edit — measured: editing a cell in a misaligned table pads the header to match. `owned` replaces that widget, so it does not happen. Realignment is available manually (`:tablerealign`, `tr`, `=` in table-nav mode). Obsidian's own formatter is reachable as `TableEditor.rebuildTable()`, which is pure and returns the formatted text, but only while its widget exists — so it is usable as a conformance oracle for our formatter, not as a dependency. - **Column resizing is not among them**, because Obsidian's table widget does not have it: measured, there is no `colgroup`, no width style on any cell, and no method matching `/resi|width/` — the grip handles **move** columns and rows rather than resizing them. So this is not a capability `owned` mode loses. - ~~**Snippets inside the owned surface are untested**~~: Fixed. A prefix expands on `Tab`, `Tab`/`Shift+Tab` move between tabstops, `Escape` ends the session and leaves insert mode in one press, and the active tabstop is selected in the cell. Expansion did not work at all beforehand — `wla` left the literal prefix and opened no session — because the router left an insert-mode `Tab` native to a nested editor that carries no snippet extension. Two gaps remain: a `|` or a newline in a snippet body damages the row, and inactive tabstops carry no highlight. It falls back to `native` with a one-time notice under each of four conditions, every one a deliberate restriction rather than a missing feature: Obsidian's own **Vim key bindings** are enabled (only the bundled engine has been measured against this surface); **mobile** (desktop only for now); the view is in **Source mode** or Reading view, since the decoration is Live-Preview-only — replacing table source in a mode meant to show source is issue #167 item 2; and the setting not being `owned` at all, because it is **opt-in**. The **Neovim backend** is no longer among them: the surface renders while RPC is connected, as a presentational table. - **Per-view subsystems are duplicated into cell editors**: Obsidian creates a separate CM6 `EditorView` per table cell, so every extension registered through `registerEditorExtension()` is instantiated again for each one-line cell document. Two are now withheld: the treesitter bridge — the most expensive, a WASM parser plus a retained tree per view — and the scrolloff enforcer, measured running 8 times inside a cell across two keystrokes. It cannot decide in its own create function, because a cell editor is **constructed detached** (measured at `isConnected: false`, with no `.cm-table-widget` ancestor), so the decision and the parse are deferred to the first update on a connected view. The four gutters (line numbers, sign column, fold column, status column) are built with CodeMirror's facet-based `gutter()` API, which has no per-view condition; they remain installed and are hidden with CSS (`.cm-table-widget .cm-gutters { display: none }`) rather than excluded. The remaining subsystems **cannot** be gated this way, and the reason is structural rather than a backlog: four are facet-based gutters with no per-view condition (CSS-hidden instead), four are `StateField`s or fold facets that receive an `EditorState` and never see a view, one is a properties `MutationObserver` already inert in a cell by construction, and the cursor-line highlight does not render in a cell at all — measured, so there is nothing to withhold. The misleading `skipInTableCells()` wrapper, which named a guarantee it never provided across 14 call sites, has been deleted. - **Visual block mode across cells**: `` operates within a single cell editor only. - **Ex commands from cell editors**: `:w` saves the main document (expected). `:q` closes the main tab (documented as expected behavior for v1). - **Fine-grained undo**: Cell edits are atomic in the main document's undo stack. Individual keystrokes within a cell editor are not separately undoable in the main editor. - **Animated cursor does not animate between cells**: When the animated cursor is enabled, cross-cell navigation (`h`/`j`/`k`/`l`) snaps the cursor to the destination cell instead of smoothly animating the transition. Each cell editor has its own `CursorController` instance — crossing cells destroys one and creates another. A token-based position handoff seeds the new controller from the old controller's screen position, but the global canvas (`position: fixed` on `.app-container`) renders behind table cell content due to CSS stacking contexts, so the transition animation is not visible. The native vim cursor (BlockCursorPlugin) is used as the steady-state renderer inside cells. Within a single cell, cursor movement animates normally via the native vim cursor's blink/redraw cycle. - **Which-key in embedded editors**: Which-key popups work in table cell editors and textarea vim overlays (bundled vim mode only). The popup renders in the parent `MarkdownView.contentEl` (or `.modal-container` for textarea overlays in modals). User keymaps are fully available — the codemirror-vim keymap is global (`defaultKeymap` at module level). Embedded editors bypass the which-key show delay for immediate feedback. Settings hot-reload does not update active embedded editors (acceptable — they are short-lived); the next editor opened picks up updated config. - ~~**Viewport does not follow cursor in long tables**~~: Fixed. In table-nav mode, navigating past the visible viewport left the highlighted cell off-screen. CM6 cannot scroll to positions inside opaque block widgets. Fixed with an `EditorView.scrollHandler` facet that intercepts scroll requests during table-nav and adjusts `scrollDOM.scrollTop` based on the highlighted cell's DOM bounding rect. ([#136](https://github.com/saberzero1/motions/issues/136)) - ~~**Cross-cell cursor bounce-back on macOS**~~: Fixed. `scheduleCrossing()` deferred focus changes raced with Obsidian's table widget handlers on macOS Electron. Replaced `MessageChannel` with `requestAnimationFrame` to defer until after the full event dispatch cycle. ([#136](https://github.com/saberzero1/motions/issues/136)) - ~~**Escape does not return to table-nav after Enter cell entry**~~: Fixed. The cell editor's vim keydown observer consumed Escape before the Scope handler could intercept it, and the cell editor's vim state had `mode: null` during initialization causing `isVimIdle()` to return false. Fixed with a capture-phase `keydown` listener and `isCellVimIdle()` that treats null mode as idle. ([#136](https://github.com/saberzero1/motions/issues/136)) - ~~**Viewport snaps to top of table on entry**~~: Fixed. Entering table-nav on a long table scrolled the viewport to the table top. During the 80ms entry debounce, Obsidian's native cell editor opened and scrolled the table into view. Fixed by locking `scrollTop` during the debounce window via a scroll event listener. ([#136](https://github.com/saberzero1/motions/issues/136)) - ~~**Table-nav ignores scrolloff setting**~~: Fixed. Cell navigation used hardcoded 5px margins instead of the user's `scrolloff` value. With `scrolloff=999`, the highlighted cell was not centered. Fixed by reading `getScrolloffMargin()` in both `syncCursorToActiveCell` and `tableNavScrollHandler`. ([#136](https://github.com/saberzero1/motions/issues/136)) - ~~**Parent cursor visible next to table during cell editing**~~: Fixed. When entering cell edit from table-nav, the parent editor's vim cursor appeared next to the table widget with oversized height. `enterCellEdit()` cleared cursor suppression immediately, but cell editor focus was deferred by 150ms. During the gap (and via stale `requestMeasure` callbacks after focus transfer), the parent's `BlockCursorPlugin` rendered the unsuppressed cursor at the table-range position. Fixed by removing the premature `clearCursorSuppressedForView` + `resumeAnimatedCursorForView` from the nav→edit transition entirely — suppression stays active and is managed by the exit paths (`exitCellEditToNav`, `exitTable`). ([#136](https://github.com/saberzero1/motions/issues/136)) - ~~**Horizontal scrolling missing in table-nav**~~: Fixed. Navigating to off-screen columns in wide tables left the highlighted cell outside the visible viewport. The table widget had `overflow: visible` which prevented any horizontal scrolling. Fixed by changing the widget to `overflow-x: auto` during table-nav mode and scrolling the widget element directly via `scrollHighlightedCellIntoView()`. ([#167](https://github.com/saberzero1/motions/issues/167)) - **Cursor and state stuck after LP→source switch during table-nav**: Switching from Live Preview to source mode while table-nav is active leaves `setKeyInterceptActive(true)`, cursor suppression, and Obsidian Scopes stuck because the `TableNavController` ViewPlugin is never destroyed or updated during view reconfiguration. A `window` capture-phase `keydown` safety handler clears the key intercept flag on the first keypress (Escape), restoring vim key processing. Cursor suppression and stale Scopes are not automatically cleared — they require exiting the stale table-nav state manually. Full cleanup is blocked by the ViewPlugin not receiving lifecycle events during mode switches. ([#167](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/119)) - ~~**Unhandled keys swallowed in cell selection mode**~~: Fixed. ([#120](https://github.com/saberzero1/motions/issues/120)) - ~~**Which-key popups missing in cell selection mode**~~: Fixed. ([#120](https://github.com/saberzero1/motions/issues/120)) - ~~**Picker focus stays on table widget**~~: Fixed. ([#120](https://github.com/saberzero1/motions/issues/120)) - ~~**Table-nav key handler intercepts keys during modal/picker interaction**~~: Fixed. ([#120](https://github.com/saberzero1/motions/issues/120)) - ~~**Modifier key combos consumed by vim during cell selection**~~: Fixed. ([#120](https://github.com/saberzero1/motions/issues/120)) - ~~**Ex command dialog keys consumed by table-nav handler**~~: Fixed. ([#120](https://github.com/saberzero1/motions/issues/120)) - ~~**Clicking outside table in embedded mode does not exit table-nav**~~: Fixed. ([#121](https://github.com/saberzero1/motions/issues/121)) - ~~**Click-outside handler exits during modal interaction**~~: Fixed. ([#120](https://github.com/saberzero1/motions/issues/120)) - ~~**Header-only tables enter table-nav**~~: Fixed. ([#121](https://github.com/saberzero1/motions/issues/121)) - ~~**Stale table-nav state after document content replacement**~~: Fixed. ([#119](https://github.com/saberzero1/motions/issues/119), [#120](https://github.com/saberzero1/motions/issues/120)) - ~~**Cursor displacement when entering table-nav**~~: Fixed. ([#121](https://github.com/saberzero1/motions/issues/121)) - ~~**Cell editor cursor shapes**~~: Fixed. Native cell editors receive focus correctly — no `.cm-focused` dynamic stylesheet hack needed. - ~~**Wikilink cursor displacement in table cells**~~: Fixed. The native editor handles wikilink rendering at the decoration layer, eliminating the cursor displacement that affected the old custom widget. ([#121](https://github.com/saberzero1/motions/issues/121)) - ~~**Pipe character (`|`) swallowed in table cells**~~: Fixed. The native editor automatically escapes `|` as `\|` in the document source. - ~~**`
` conversion in cell editors**~~: Fixed. The native editor handles `
` ↔ newline conversion automatically. The `cellBrToNewline`/`cellNewlineToBr` utilities are removed. - ~~**Visual mode highlighting in cell editors**~~: Fixed. Charwise visual mode (`v`) in cell editors now shows selection highlighting via a `CSSStyleSheet` on `document.adoptedStyleSheets` that forces `::selection` visibility in `.cm-vimVisual:not(.cm-vimVisualLine)` scoped to `.vim-table-cell-editor`. Linewise visual mode (`V`) uses the fork's `linewiseVisualHighlight` ViewPlugin, which is focus-independent (checks `vim.visualLine` and `vim.sel` only). ([#19](https://github.com/saberzero1/motions/issues/19)) - ~~**Wikilink and formatting loss after cell edit**~~: Fixed. Two issues: (1) the cell editor read the cell's initial value from `wrapper.textContent` (the rendered DOM), which strips markdown syntax — `[[note-a]]` became `note-a`. Now reads raw markdown from the document source via `getCellDocumentRange()`. (2) On cell editor close, the cell content was restored as plain `textContent` without re-rendering. Now uses `MarkdownRenderer.render()` to restore proper inline formatting (wikilinks, bold, italic, code) after the editor is destroyed. ([#19](https://github.com/saberzero1/motions/issues/19)) - ~~**Tab cell navigation freezes editor**~~: Fixed. Pressing `Tab` in insert mode inside an embedded table cell editor froze the editor — the cursor disappeared, vim mode got stuck in Insert mode, and `Escape` stopped working. Root cause: `exitCellEdit()` scheduled a 50ms `refreshAfterOp()` timer that was non-cancellable and had no state guard. When `Tab` called `exitCellEdit()` → `enterCellEdit()` synchronously, the deferred refresh fired while the new cell editor was active — removing its key handlers, potentially orphaning the editor DOM, and leaving the controller in an inconsistent state. Fixed with defense-in-depth: cancellable/deduplicated refresh timer, state guard in `doRefreshAfterOp`, `skipRefresh` parameter for Tab transitions, and belt-and-suspenders timer cancel in `enterCellEdit`. Tab at boundary cells (last cell + Tab, first cell + Shift-Tab) now returns to table-nav mode instead of silently re-entering the same cell. ([#92](https://github.com/saberzero1/motions/issues/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 `
` tags on cell editor close (`cellNewlineToBr`) and converting `
` tags back to newlines on cell editor open (`cellBrToNewline`). This preserves multi-line cell content using standard HTML `
` tags that Obsidian renders correctly within table cells. Both helpers are in `table-utils.ts` and handle all `
` variants (`
`, `
`, `
`, case-insensitive). ([#115](https://github.com/saberzero1/motions/issues/115)) - ~~**Multiple tables per note — wrong table selected**~~: Fixed. In embedded mode, when a note contained two or more tables, entering table-nav mode on any table other than the first always attached the cell highlight, key handlers, and cell editor to the first table's DOM widget. Root cause: `findWidgetEl()` in `table-nav-controller.ts` queried all `.vim-table-rendered` elements and returned the first match without considering which table the cursor was in. Fixed by adding a `tableFrom` parameter to `findWidgetEl()` and using CM6's `view.posAtDOM()` to correlate each widget element with its document position, returning the nearest match. Additionally, exiting cell edit mode on the second table caused the cursor to jump back to the first table. Two sub-bugs: (1) `activeEditTableRange` was cleared before transactions that modify the document (`closeCellEditor` dispatch, `tableRealign` dispatch), causing `buildDecorations` to create a `Decoration.replace` widget for the active table and displacing the cursor — fixed by keeping `activeEditTableRange` set throughout the exit and refresh lifecycle, ensuring the StateField fast-path (`prev.map(tr.changes)`) fires during all document-changing dispatches. (2) After `tableRealign`, `doRefreshAfterOp` used `Array.find()` with a 200-position threshold to re-locate the table, which returned the **first** table within range rather than the **closest** — with two tables less than 200 positions apart, the first table always matched. Fixed by replacing `Array.find` with a nearest-match loop. ([#117](https://github.com/saberzero1/motions/issues/117)) ## Undo tree visualization **Status**: Implemented. Shadow undo tree tracking branching history parallel to CM6's linear undo stacks. `g-`/`g+` navigate chronologically across all branches with ChangeSet-based buffer content restoration. `:earlier`/`:later` navigate by count, time (`Ns/Nm/Nh/Nd`), or save point (`Nf`). Sidebar view with DOM tree rendering, click/keyboard navigation, collapse/expand. Optional persistence via `set undofile`. **Known limitations**: - **ChangeSet composition for deep navigation**: Navigation dispatches sequential `addToHistory.of(false)` transactions (one per tree node on the path). For very deep trees (50+ levels), this dispatches many transactions — imperceptible in practice but theoretically slower than single-transaction composition. - **Persistence after external file modification**: When `undoFile` is enabled and the file is modified outside Obsidian between sessions, persisted ChangeSets become invalid (document length mismatch). The tree structure is preserved for `:undolist` display, but navigation is disabled for that session. An Obsidian Notice is shown when a stale tree is detected (once per file per session). Detection uses file size comparison — same-length substitutions are not caught. - ~~**Per-file tree map memory**~~: Fixed. Undo trees are now evicted from memory when all editors for a file are closed (on `active-leaf-change`). Dirty trees are persisted before eviction when `undoFile` is enabled. Persisted data on disk is not deleted — reopening the file restores from persistence or starts fresh. - **No CM6 undo stack integration for cross-branch navigation**: `g+`/`g-` use `addToHistory.of(false)` transactions, so pressing `u` after `g-` undoes the last user edit, not the navigation. This matches Neovim behavior. ## Flash motions **Status**: Working (Phase 1 + Phase 2 + Phase 3). Flash-style enhanced `f`/`F`/`t`/`T` motions show labels on all visible matches when 2+ matches exist. Single-match cases autojump (stock Vim behavior preserved). **Known limitations**: - ~~**Highlight rectangles hardcoded to 8×16px**~~: Fixed. Highlight boxes now dynamically measure actual character dimensions via `coordsAtPos()`. CSS uses custom properties (`--vim-motions-flash-w`, `--vim-motions-flash-h`) with fallbacks for user CSS snippet compatibility. ([#75](https://github.com/saberzero1/motions/issues/75)) - ~~**Labels obscure matched text**~~: Fixed. Labels are now positioned at the END of the matched text (after the last matched character), matching flash.nvim's default `after = true` behavior. Match highlights render behind labels during the label phase. During label narrowing, match highlights persist for all targets while only labels narrow. ([#75](https://github.com/saberzero1/motions/issues/75)) - **EasyMotion label shift**: Labels for EasyMotion motions (word, char, line, search) now appear one character to the right of the target — after the target character instead of on top of it. This is a deliberate change matching the label-after-match positioning used by flash. The jump destination is unchanged. - **Label vertical centering**: Labels are vertically centered within the line height. On lines with taller fonts (headings), labels sit centered rather than top-aligned. Enable `set labelmatchfontsize` to scale label font to match the target line's font size (e.g., larger labels on headings, matching Neovide-style behavior). Disabled by default. - ~~**Line motions target hidden formatting in Live Preview**~~: Fixed. EasyMotion line motions (`j`/`k`) now skip hidden markdown formatting (heading markers, bold/italic syntax) in Live Preview using `skipHiddenPrefix()` to find the first visually visible character via `coordsAtPos()`. ([#79](https://github.com/saberzero1/motions/issues/79)) - **RTL (right-to-left) label positioning**: Jump labels always appear to the right of the target, which is incorrect for RTL text. When Obsidian's editor direction is set to RTL, labels should appear to the left. No existing jump-label implementation (flash.nvim, leap.nvim, vim-easymotion, VSCodeVim, AceJump) handles RTL — this is a universally unaddressed problem. Obsidian provides per-line RTL detection via `dir` attributes on `.cm-line` elements, and CM6 offers `EditorView.textDirectionAt(pos)`. A proper fix requires per-target direction detection, flipped label placement, adjusted collision logic, and RTL testing infrastructure. Deferred as a separate feature. ([#79](https://github.com/saberzero1/motions/issues/79)) - **No macro recording**: Flash label selection is not recorded in macros. Macros capture the search character (`f{char}`) but not the label keypress. This is the same limitation as EasyMotion. - ~~**No dot-repeat for label selection**~~: Clarified. Dot-repeat after `df{char}{label}` already works correctly — the fork stores the resolved position via `_asyncMotionTarget` and `repeatLastEdit` replays the operator to the same relative offset. The label UI does not re-appear during dot-repeat, which is correct vim behavior (Neovim's `.` never re-shows interactive selection UI). - **No remote operations**: flash.nvim's remote mode (`yr{target}` to yank at a distance without moving cursor) is not implemented. This requires vim state manipulation not available in the codemirror-vim fork. - **No treesitter mode**: flash.nvim's treesitter node selection is not feasible — CM6 uses Lezer, not treesitter, and does not expose node selection APIs. - ~~**Count prefix ignored with labels**~~: Fixed. `3f{char}` now jumps directly to the 3rd match without showing labels. When the count exceeds available matches, the last match is used (Neovim parity). `f{char}` without a count prefix still shows labels for 2+ matches. Works in operator-pending mode (`d3f{char}`) and with `t`/`T` till motions. - ~~**Multi-line `t` column 0**~~: Fixed. When `t{char}` finds a match at column 0, the "before" position now wraps to the last character of the previous line instead of excluding the target. Both flash (`applyTillOffset`) and EasyMotion (`findTillTargets`) are fixed. Matches at column 0 of line 0 (no previous line to wrap to) are still excluded. - **Programmatic Escape**: Flash labels can only be dismissed by DOM keyboard events (real keypresses). Programmatic `Vim.handleKey(adapter, '')` does not reach the label handler. This mirrors the same limitation in EasyMotion. - **Jump mode key binding is registration-time**: Changing `flashjumpkey` at runtime requires a plugin reload or settings change that triggers `reloadFeatures()`. The key is bound via `mapCommand` during registration. - **Jump mode overrides `s`**: When enabled, `s` in normal mode triggers flash jump instead of substitute (`cl`). Visual mode `s` retains its default `c` mapping. - ~~**Jump mode `s` conflicts with surround `cs`/`ys`/`ds`**~~: Fixed. The operator-prefix shadow resolver (see [Operator-prefix key dispatch](#operator-prefix-key-dispatch-timeoutlen)) automatically defers flash's `s` motion when surround's `s` action is a partial match in operator-pending mode. `cs"`, `ds"`, `ysiw"` work correctly with flash jump enabled. The resolver uses a configurable timeout (`operatorshadowtimeout`, default 1000ms) — if no surround target character arrives within the window, the flash motion executes as fallback. - **clever-f 5s timeout**: The clever-f repeat detection uses a 5-second window. After 5 seconds, `f{same-char}` is treated as a new flash search. - **Incremental jump check_jump**: When `pattern.length >= minPatternLength`, typed characters are checked as labels first, then as search extensions. A character that matches both a label and a valid search continuation will jump rather than narrow. Below `minPatternLength`, all characters extend the search pattern. - **skipChars same-line only**: Label conflict skipping only checks the character immediately after each match on the same line. Matches at end-of-line do not conflict with any label. - **Search mode post-commit only**: Flash search labels appear AFTER committing a `/` or `?` search with Enter, not during typing. This is a deliberate simplification from flash.nvim to avoid label-vs-search-char disambiguation. Labels auto-clear on any non-label key. - **Search mode single match**: Labels are only shown when 2+ matches exist. Single-match searches navigate directly without labels. - **Search labels with `*`/`#`**: Word-under-cursor search (`*`/`#`) does not trigger flash search labels because it bypasses the search dialog. - ~~**Labels missing from top half of viewport with frontmatter scrolled off-screen**~~: Fixed. In Live Preview mode, when frontmatter properties were collapsed into a widget and scrolled off-screen, flash labels only appeared in the bottom half of the viewport. Root cause: `getVisibleRange()` in `src/easymotion/targets.ts` used `view.lineBlockAtHeight()` to determine visible document lines, but CM6's height map uses estimated heights for off-screen widgets — the collapsed frontmatter widget's estimated height differed from its actual rendered height, causing `coordsAtPos()` to return `null` for targets near the viewport top. Fixed by using `view.visibleRanges` (which reflects actually-rendered document ranges) instead of `lineBlockAtHeight`. This also affected EasyMotion target scanning. ([#114](https://github.com/saberzero1/motions/issues/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`), 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 `` while `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 :buffers`) 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 ``), motions extending non-motions (`il` vs `i`), and `operatorPending` actions without an active operator (`s` vs `s`). This means `t` mappings with longer partials (e.g., table nav `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 `is{char}text` 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](https://github.com/kylechui/nvim-surround/issues/301)). ([#82](https://github.com/saberzero1/motions/issues/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 `s` in the same insert session is not preserved in dot-repeat, matching canonical behavior. ## ~~Insert-mode surround macro recording~~ (Fixed) **Status**: Fixed. `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., `s)`) to the macro register. Previously, `logKey` was only called from `handleKeyNonInsertMode`. **Test coverage**: `test/specs/surround.e2e.ts` — "macro register should contain `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](https://github.com/saberzero1/motions/issues/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. `dw{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`. The fork's `evalInput` resolves the promise and applies the pending operator (`d`, `c`, `y`) to the resulting position. Visual mode (`v` + easymotion) also works — the fork updates the visual selection head/anchor when an async motion resolves during visual mode. **Remaining limitations**: - ~~Dot-repeat (`.`) does not replay operator-pending easymotion operations~~ — Fixed. The fork now stores the resolved async motion position as a relative offset in `lastEditInputState._asyncMotionTarget`. During dot-repeat, `repeatLastEdit` applies the operator with the stored offset instead of re-executing the async motion overlay. - Char-based easymotions (`f`, `F`, `s`, `t`, `T`) in operator-pending mode require an intermediate search-character keypress which adds complexity to the async flow - ~~Capital letter (Shift+key) search not working~~ — Fixed. `waitForKey()` resolved on the `Shift` keydown event before the actual character arrived. The modifier-key guard (`e.key.length !== 1`) now suppresses modifier-only keys, matching `waitForLabel()`'s existing pattern. ([#84](https://github.com/saberzero1/motions/issues/84)) - ~~Inclusive motions (`f`, `t`, `e`) exclude the target character in operator-pending mode~~ — Fixed. EasyMotion motions were registered with empty `motionArgs`, so the fork treated all motions as exclusive. Added per-motion `motionArgs: { inclusive: true }` matching native Vim semantics. ([#109](https://github.com/saberzero1/motions/issues/109)) - ~~EasyMotion line motions (`j`/`k`) operate characterwise instead of linewise in operator-pending mode~~ — Fixed. Added `motionArgs: { linewise: true }` to both `easyMotionLine` and `easyMotionLineBack` definitions. `dj{label}` now deletes full lines (linewise), matching native Vim `dj` semantics. - ~~EasyMotion forward motions do not set `motionArgs.forward`~~ — Fixed. All directional EasyMotion motions now set `motionArgs.forward` (`true` for forward, `false` for backward), enabling the fork's `clipToLine` function for forward cross-line operations. - ~~`EXTRA_DEFS` bidirectional motions lack `motionArgs`~~ — Fixed. `easyMotionBdEndWord`, `easyMotionBdEndWORD`, `easyMotionBdTill` now set `inclusive: true`, and `easyMotionBdLine` sets `linewise: true` via `Object.assign(motionArgs, defArgs)` inside the motion function body. This bypasses the `mapCommand` limitation — the motion mutates the received `motionArgs` directly, so operators apply correctly when invoked via Lua `vim.keymap.set` remapping. - ~~`easyMotionRepeat` uses `cm.setCursor()` directly~~ — Fixed. `easyMotionRepeat` is now registered as a `defineMotion` (was `defineAction`) with a `mapCommand` binding at `.`. The motion function inherits `motionArgs` from the last executed EasyMotion motion via `Object.assign`, so operators like `d`, `c`, `y` apply correctly with the proper `linewise`/`inclusive`/`forward` flags. **Test coverage**: `test/specs/easymotion-comprehensive.e2e.ts` validates d/c/y + easymotion flows, capital letter char search, linewise line motions (`d+j`, `y+j`, `d+k`), inclusive/exclusive motion behavior, and operator-pending repeat (`d+.` 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](https://github.com/saberzero1/motions/issues/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 `tr`) reformats a table so all columns have uniform width, respecting `:---`/`---:`/`:---:` alignment markers in separator rows. Auto-format: ~~typing `|` in insert mode on a table line triggers automatic column realignment~~ — replaced with format-on-exit. Tables are now automatically realigned when the cursor leaves the table range after editing. No formatting happens mid-edit, so the cursor stays where you expect it. Typing `||` on a new line within a table generates a separator row matching the header's column count. Manual realignment is available via `tr` or `:tablerealign`. ([#66](https://github.com/saberzero1/motions/issues/66), [#67](https://github.com/saberzero1/motions/issues/67)) ~~Typing `|` moves cursor to the left of `|`~~ — Fixed. The mid-edit `|` interception that caused cursor jumps has been removed. ([#66](https://github.com/saberzero1/motions/issues/66)) ~~Escaped `\|` characters treated as cell boundaries during editing~~ — Fixed. Escaped pipes, wikilinks (`[[page|alias]]`), and other `|`-containing inline syntax are no longer mishandled during table editing because the auto-format no longer runs mid-edit. In `native` mode, the native editor automatically escapes `|` as `\|` in the document source. ([#67](https://github.com/saberzero1/motions/issues/67)) The following are intentionally not implemented: - ~~**`j`/`k` column tracking**~~: Fixed. `h`/`j`/`k`/`l` now cross cell boundaries in native table cell editors via `defineMotion` overrides. The overrides delegate to the originals outside table cells, preserving stock vim behavior. - **`Tab`/`Shift-Tab`**: These conflict with Obsidian's built-in table Tab handling and insert-mode tab completion. ## Table widget in Live Preview The plugin uses Obsidian's native table editor in Live Preview. Two rendering modes are available via `set tablewidget`: - **`native`** (default): Uses Obsidian's built-in `cm-table-widget`. Vim is injected into cell editors via `registerEditorExtension()`. The native editor handles wikilinks, pipe escaping, cursor positioning, and `
` conversion automatically. Cross-cell `h`/`j`/`k`/`l` navigation is always active in native mode, independent of the `tablenav` setting. - **`raw`**: Hides the widget. **In Live Preview this renders the table as nothing at all** — see the measured subsection below; it does not fall back to showing markdown source. Use **Source mode** for source-style table editing instead. - **`owned`** (experimental, **display only**): the plugin block-replaces the table with its own renderer at the highest CodeMirror precedence, which removes Obsidian's widget from the DOM entirely. **Editing inside the table is not supported yet.** The renderer is a passive element, and a block-replaced range cannot host a caret: measured, `coordsAtPos` collapses every offset in the range to the widget's edges, the selection relocates on its own, vertical motion treats the whole table as one display line, and insert-mode typing lands outside the table as a stray line. Rendering is therefore correct and editing is not. Use `native` to edit. Gated to desktop, Live Preview, the bundled engine, and a disconnected Neovim backend; each blocker falls back to `native` and says so once. Old values (`off`, `cursor`, `always`, `embedded`) are automatically migrated to `native` or `raw`. ~~**Cursor disappears when entering a table in source mode or raw mode**~~: Fixed. The `mainEditorTableCursorGuard` suppressed the vim cursor whenever the cursor entered a text range matching table syntax (lines starting with `|`), regardless of whether a native table widget was actually visible. In source mode (no `.cm-table-widget` elements) and raw mode (widgets hidden via `display: none`), the cursor was suppressed with no alternative cursor shown. Fixed by adding a `hasVisibleTableWidget()` check that verifies at least one `.cm-table-widget` element with a non-null `offsetParent` exists before suppressing the cursor. This also short-circuits the `findTableRanges()` document scan when no visible widgets exist. This same root cause made table navigation with `enableTableNav=false` appear broken — cursor movement worked but the invisible cursor made it seem like `j`/`k`/`↓`/`^N` had no effect. ([#132](https://github.com/saberzero1/motions/issues/132), [#136](https://github.com/saberzero1/motions/issues/136)) **Table manipulation commands** (`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](https://github.com/saberzero1/motions/issues/136) regression tests. The "suppress the decoration rather than hide the element" fix is now measured as achievable: a `Prec.highest` block-replace decoration supplied from a `StateField` removes Obsidian's table widget from the DOM entirely. That is the foundation of the planned owned table renderer, which is also what will let `raw` be retired. ## Vimrc soft-reload Vimrc maps and settings are soft-reloaded when the vimrc file is modified — changes to `nmap`, `set`, and other map/setting commands take effect without reloading the plugin. The plugin watches the vimrc file via `vault.on('modify')` and re-applies maps and settings on change. ~~**Limitation**: `exmap` definitions only parsed during initial load~~ (Fixed). `exmap` definitions are now soft-reloaded — `softReloadVimrc()` calls `applyVimrcCommands()` which processes `exmap` entries, and `vim.defineEx()` replaces existing handlers. Adding, modifying, replacing, or removing `exmap` entries takes effect on save. The fork's `undefineEx()` API cleans up stale handlers — exmap names are tracked per vimrc load and unregistered before re-applying on soft-reload. ### Config file resolution The plugin searches the vault root for config files using a fallback chain (first match wins): **Vimrc**: `vimrc`, `.vimrc`, `init.vim`, `.init.vim`, `obsidian.vimrc`, `obsidian.vim`, `.obsidian.vimrc`, `.obsidian.vim` **Lua**: `init.lua`, `.init.lua`, `obsidian.init.lua`, `.obsidian.init.lua`, `obsidian.lua` Non-dotfile names are preferred because Obsidian Sync skips dotfiles. The `.obsidian.*` variants are last in the chain for backward compatibility. A custom path can be set via **Settings → Vim Motions → Vimrc & key bindings → Custom vimrc path** (or Custom init.lua path). When set, the custom path is used directly and the fallback chain is skipped. The setting provides file-suggest autocompletion. The settings UI shows which file is currently in use ("Currently using: {path}") or a not-found warning for invalid custom paths. ([#34](https://github.com/saberzero1/motions/issues/34)) **External paths (desktop only)**: Custom paths can be absolute filesystem paths (e.g. `~/.config/obsidian/init.lua`, `C:\Users\\.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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/56), [#125](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/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 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 result - `getKeymap('normal')` shows the `L → $` entry after runtime application - After vimrc load, `vimrcMaps` is empty and `vimrcCommandCount` is 0 — the file was not read successfully - The mapping mechanism (`ExCommandDispatcher.map`, `_mapCommand`, `doKeyToKey`) is correct — the issue is in file I/O timing during the `active-leaf-change` handler Mitigation (multi-layered): 1. **`stat()` readiness probe**: `readVimrcFile` now calls `app.vault.adapter.stat(path)` before `read()` to verify the file exists in the vault index. This avoids attempting reads on non-existent files and provides `stat.size` to distinguish genuinely empty files from timing-empty reads. 2. **Two-phase parsing**: File reading/parsing is decoupled from command application. `readAndParseVimrcFile` parses the vimrc without needing a CM adapter; `applyVimrcCommands` applies all commands, deferring cm-dependent ones to `pendingExCommands`. 3. **Smart retry with backoff**: When `stat.size > 0` but `read()` returns empty (timing issue), retries with exponential backoff (50ms, 100ms, 200ms, 400ms — 750ms total). Genuinely empty files (`stat.size === 0`) skip retries entirely. 4. **User-facing Notice on exhaustion**: If all retries fail on a non-empty file, a Notice is shown: "Vim Motions: vimrc found but could not be read — try reloading the plugin." This surfaces the issue for user reports. 5. **`vimrcLoading` try/finally**: The `vimrcLoading` flag is now reset in a `finally` block, so a failed `loadVimrc()` call no longer permanently blocks future retry attempts on subsequent `active-leaf-change` events. 6. **Lua loader parity**: The same `stat()`+retry pattern is applied to `readLuaFile()` in `src/lua/loader.ts`, which previously had no retry logic at all. Workaround: if vimrc mappings are not applied despite the improved retry mechanism, reload the plugin via **Settings → Community plugins** (disable then enable). At runtime, mappings can be applied via Obsidian's developer console: `CodeMirrorAdapter.Vim.map('L', '$', 'normal')`. ## `set textwidth` via vimrc may not affect `gq` `set textwidth=20` in `.obsidian.vimrc` may not change the wrap width used by the `gq`/`gw` operators if the vimrc file is not loaded successfully (same file I/O timing issue as `nmap L $` — see improved retry mechanism above). The `textwidthSetExplicitly` guard in `options.ts` correctly prevents CM Vim's `defineOption` callback from resetting the value when the vimrc does load successfully. With the vimrc-settings parity changes, `set textwidth=N` in vimrc also updates `this.settings.textwidth` via the `onSettingOverride` callback. The `textwidth` setting is now available in the Settings UI (**Settings → Vim Motions → Vim engine → Text width**). The `getTextwidth()` function used by `gq`/`gw` still reads from the module-level variable, so the vimrc I/O timing issue can still cause the value to not propagate. Workaround: if `set textwidth=N` is not taking effect, reload the plugin. At runtime: `CodeMirrorAdapter.Vim.setOption('textwidth', 20)`. ## `noremap` cannot swap built-in single-key motions `nnoremap j k` / `nnoremap k j` does not swap the `j` and `k` motions. This is a codemirror-vim architectural constraint: when a `noremap` mapping's rhs is dispatched, the key handler skips all user-defined keymap entries and only searches the default keymap. Since user-defined entries are inserted at the front of the keymap array via `unshift`, the `noremap` dispatch (which starts at `keyMap.length - defaultKeymapLength`) correctly finds the original motion. However, the lhs side of the swap still resolves to the original motion as well, because codemirror-vim's `noremap` flag is tracked globally during dispatch — meaning both sides of a swap end up resolving to the default keymap. This limitation is confirmed upstream in [obsidian-vimrc-support issue #16](https://github.com/esm7/obsidian-vimrc-support/issues/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 `` 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 q ZZ` and nothing bound to bare ``, pressing `` 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 q ZZ` then `a` `` 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 — `` 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: - `` follows your `` mapping, because `` _is_ Escape — both send `0x1b`. `` does not, because it is a distinct key. That makes `` a dependable way out of insert mode regardless of what `` is bound to. - A recursive `imap ` 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 `` or ``, 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=` 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](https://github.com/saberzero1/motions/issues/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 (``, ``, 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](https://github.com/saberzero1/motions/issues/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=` (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 t Table icon=table color=blue`). 8 named Obsidian colors (`red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `pink`) map to `var(--color-)` CSS variables. Arbitrary CSS color strings are also accepted (sanitized against injection). Default icon color is `--text-muted`. Built-in groups register default icons: Table (`table`, blue), EasyMotion (`zap`, yellow), Harpoon (`anchor`, orange). User-configured icons override defaults via the standard priority merge (Lua > vimrc > Settings). When icons are enabled, rows without an assigned icon receive an empty spacer span to maintain column alignment across all rows. When icons are disabled globally, no icon spans or spacers are rendered. ### Grouping When **Which-key leader grouping** is set to "Grouped" (default), bindings sharing a common prefix key are collapsed into a single group entry (e.g. `t` → `Table (+11)`). Pressing the group key drills down to show only the bindings within that group. Groups are sorted before ungrouped entries. Setting the mode to "Flat" restores the original behavior of listing all bindings individually. Grouping applies to all completions — not just leader-scoped bindings. Any multi-key prefix (`g`, `z`, `[`, `]`, user-defined sequences) benefits from grouping when multiple completions share a next key. ### Group labels Groups are labeled with a generic `+N keys` text by default. Custom labels can be configured via **Settings → Vim Motions → Which-key group labels** using the full key prefix: - Leader-relative groups: use the leader character + prefix (e.g. `\t` for table commands under leader `\`) - Non-leader groups: use the raw prefix (e.g. `gr` for the replace-with-register operator, `cs` for surround changes) - `` token: expanded to the actual leader key (e.g. `t` resolves to `\t` with default leader) Built-in features register default labels (Table, EasyMotion) that user entries can override. Whitespace in the prefix field is trimmed. ### ~~EasyMotion commands shown incorrectly with space leader~~ (Fixed) **Status**: Fixed. `LeaderRegistry.addBinding()` and `addGroupLabel()` now normalize keys via `normalizeVimKey()` at storage time, ensuring consistent `` notation across all comparison paths. ([#94](https://github.com/saberzero1/motions/issues/94)) EasyMotion commands (prefixed with ``) 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 (`""`), 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 `` as a "special key" (like ``, ``) and silently dropped all entries whose first key was `` from the grouping display. Fixed by exempting `` 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](https://github.com/saberzero1/motions/issues/58)) `vim.keymap.set("n", "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`), 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 `` notation when storing keymaps (`_mapCommand` → `normalizeKeyString`), and `getCompletions()`/`getKeymap()` return keys in this normalized form. The fork's key event handler (`vimKeyFromEvent`) also emits `` 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 `` event keys — all lookups missed. The leader-only which-key mode additionally never triggered with space as leader because `"" !== " "`. 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](https://github.com/saberzero1/motions/issues/186)) With space as the leader, `r` replaced the character under the cursor and then opened the leader overlay as if `` had been pressed on its own. The overlay was not a real leader press — a following `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 ``: `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 ` or `:ob ` without an explicit `desc` now auto-resolve to Obsidian's native command name in the which-key popup. For example, `vim.keymap.set("n", "r", ":ob app:go-back")` displays "Navigate back" instead of the raw `:ob app:go-back` 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](https://github.com/saberzero1/motions/issues/62)) ### Limitations - **Function callbacks with `vim.cmd("ob ...")`**: When `vim.keymap.set` uses a function callback that calls `vim.cmd("ob ...")` or `vim.cmd("obcommand ...")`, the which-key popup cannot auto-resolve the Obsidian command name. Lua functions are opaque — the plugin cannot introspect the function body to extract the command ID. Use a string RHS (`:ob `) for auto-resolution, or provide an explicit `desc` option. See examples under "Mapping examples" in the Lua configuration docs. - User-defined mappings via `Vim.map()` appear in completions but without friendly descriptions when the rhs is not an `:obcommand`/`:ob` pattern (shown as the raw rhs key sequence) - The overlay does not show during macro playback or when a register prefix (`"a`) is pending - Icon IDs are validated at render time — invalid icon names (not in Obsidian's Lucide bundle) result in an empty spacer; no error is thrown - Icons in pop-out windows depend on `setIcon()` working with foreign `Document` objects — if Obsidian's API references `document` internally, icons may not render in pop-out windows ## `` 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 `` prefix (`h/j/k/l`, `v`, `s`, `c`, `q`, `o`) to work. This is also noted in the settings toggle and README. The close-tab functionality remains available via `:q`, `:quit`, `c`, or `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](https://github.com/saberzero1/motions/issues/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**: `h/j/k/l` (focus pane), `v/s` (split), `c/q` (close), `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](#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 `` 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](https://github.com/guoang/obsidian-vim-keynav) conventions. Editor behavior is unchanged. ### `Ctrl-o`/`Ctrl-i` dual purpose In editor context, codemirror-vim uses ``/`` for the within-file jumplist. In non-editor views, the global handler maps them to `app:go-back`/`app:go-forward` (Obsidian's history navigation). There is no conflict because the global handler only fires when no editor is focused. ### ~~`gt` always goes to first tab / `Ngt` count ignored~~ (Fixed) **Status**: Fixed. `gt` now goes to next tab (no count) or Nth tab (with count) in both editor and non-editor views. ([#97](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/47)) The global key handler uses a three-gate interception system (always active on desktop, independent of the workspace navigation setting): - **Structural keys** (`*`, `gt`/`gT`, ``/``, `:`) — always intercepted in non-editor views, regardless of view type. `:` is always registered; pane/tab navigation keys (`*`, `gt`/`gT`, etc.) are only registered when workspace navigation is enabled. - **Content keys** (`j`/`k` scroll, count-prefix digits, `H`/`L`, scroll commands) — only registered when workspace navigation is enabled, and only intercepted in whitelisted view types (markdown, graph, pdf, canvas, empty, image, bases). In plugin views (Spaced Repetition, Excalidraw, etc.), these keys pass through to the plugin. - **Hint keys** (`f`, `F`, `yf`, `df`) — intercepted unless an editor or input is focused. **Trade-off**: In plugin views, pressing `g` followed by a standard-gated key (e.g., `gg` for scroll-to-top) will consume the keystrokes without effect, because the `g` prefix enters the handler due to structural completions (`gt`/`gT`). Use `` 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) `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](https://github.com/saberzero1/motions/issues/104)) Yank, close, and context menu are not mapped to editor key sequences (they conflict with vim's native operators). They are registered as Obsidian commands for custom hotkey assignment: - `vim-motions:hint-open-new-pane` — "Hint: open in new pane" - `vim-motions:hint-yank` — "Hint: yank link or text" - `vim-motions:hint-close` — "Hint: close tab or pane" - `vim-motions:hint-context-menu` — "Hint: open context menu" ### Target classification Each hint target is classified by type during discovery, before label assignment. The classification determines per-action behavior: - `.workspace-leaf-content` → `pane` (focus via `setActiveLeaf`; `F` action duplicates the leaf into a new tab via `duplicateLeaf`) - `.workspace-tab-header` → `tab` (close via `leaf.detach()`) - `a[href]`, `[data-href]`, `.cm-underline`, `.cm-hmd-internal-link`, `.cm-link`, `.cm-url` → `link` (internal links navigate via `navigateWithJump`; external URLs open via `window.open()`). ~~`.cm-underline` spans in Live Preview had no `href` or `data-href` attributes, causing wikilinks and markdown links to fall through to the generic click handler (no-op on CM6 spans).~~ Fixed in three phases: (1) `resolveCmUnderlineHref()` uses `EditorView.posAtDOM()` to convert the DOM element to a document offset, then calls `findLinkAtCursor()` to extract the link target from the raw markdown text. (2) ~~Hint labels only appeared on `.cm-underline` spans, which are only present in Live Preview when the cursor is NOT on the link's line. When the cursor is on the line, wikilinks render as `.cm-hmd-internal-link` spans and markdown links render as `.cm-link`/`.cm-url` spans — neither was in `TARGET_SELECTOR`. In Source mode, wikilinks always render as `.cm-hmd-internal-link`.~~ Fixed: added `.cm-hmd-internal-link`, `.cm-link`, and `.cm-url` to `TARGET_SELECTOR` with deduplication filters to prevent multiple hints per link (aliased wikilink sub-spans, nested `.cm-underline` inside `.cm-hmd-internal-link`, markdown link URL spans when text span exists). (3) `getEditorViewFromElement()` falls back to the `MarkdownView.editor.cm` path when the DOM `.cmView.view` property is unavailable (which is the case in Obsidian's runtime). External URLs resolved by `resolveCmUnderlineHref()` are opened via `window.open()` instead of falling through to the generic click handler. ([#85](https://github.com/saberzero1/motions/issues/85)) - `input`, `textarea`, `select`, `[contenteditable]` → `input` (focus; `` copies used for layout measurement) - Child elements inside `.checkbox-container` are excluded (the container itself is the clickable toggle, not its inner ``) - `input[type="hidden"]` and disabled elements are excluded - `.cm-underline` inside `.cm-hmd-internal-link` is excluded (parent is the preferred target) - `.cm-formatting-link` spans are excluded (bracket characters `[`, `]`, `(`, `)` should not be hint targets) - Only the first `.cm-hmd-internal-link` sibling per link group is kept (aliased wikilinks `[[Target|Alias]]` produce 3 sub-spans) - Only the first `.cm-link` sibling per link group is kept (formatting brackets produce separate `.cm-link` spans) - `.cm-url` with `.cm-string` class is excluded (URL inside markdown link parentheses — the `.cm-link` text span is the hint target). Bare URLs (`.cm-url` without `.cm-string`) are kept ### Settings gating Hint actions in non-editor context require BOTH `enableWorkspaceNav` (gates GlobalKeyHandler) AND `enableHintMode` (gates hint actions). Disabling hint mode via settings stops `f`/`F`/`y`/`d` interception in GlobalKeyHandler. The existing `enableHintMode` setting controls all hint labels — in both editor and non-editor contexts. ### 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](https://github.com/saberzero1/motions/issues/98)) ### ~~Count prefix (`2F`) shifts focus to new tab~~ (Fixed) **Status**: Fixed. When using a count prefix (e.g., `2F`), focus now stays on the original leaf between activations. The `createHintAction` `run()` function saves the active leaf before `waitForHintKey` when count > 1 and restores it via `setActiveLeaf` after each activation, before scheduling the next round. `hintActivate` is now `async` and awaits `navigateWithJump()` and `duplicateLeaf()` — previously these were fire-and-forgotten via `void`, causing a race condition where `openLinkText()` could resolve after `setActiveLeaf(originalLeaf)` and steal focus back to the new tab. The race manifested on slower machines or with heavier vaults. The `hintMode` vim action (`h`) now passes `actionArgs.repeat` to `activate()`, enabling count prefix in editor context as well. ([#98](https://github.com/saberzero1/motions/issues/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 `` and `` 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: ```vim nmap :back nmap :forward ``` ## `gf` opens file switcher, not file path under cursor Standard Vim's `gf` opens the file whose path is under the cursor. In Obsidian, bare file paths in notes are uncommon — most navigation uses `[[wikilinks]]` (handled by `gd`). Our `gf` opens Obsidian's quick switcher instead, which lets users search any file by name. This is more practical for a note-taking context. ## Mobile support The plugin is **disabled by default on mobile** (`enableOnMobile: false`). Most mobile users sync the plugin to their vault without a hardware keyboard attached, and the Vim engine puts the editor into Normal mode with no obvious way to return to typing (soft keyboards lack `Escape` and `:`). ([#52](https://github.com/saberzero1/motions/issues/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 (``) | ✅ | ✅ | ❌ 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`~~ | ~~Recursive fold open/close/toggle~~ | ~~Maps to the same action as `zo`/`zc`/`za`~~ | Fixed. `zO`/`zC`/`zA`/`zD` now operate recursively using range containment on CM6's foldable regions. | | ~~`zn` / `zN`~~ | ~~Fold none (disable folding) / fold normal (re-enable)~~ | ~~Not implemented~~ | 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) **Status**: Fixed. The `:m`/`:move` and `:t`/`:copy`/`:co` ex commands now fully support absolute addresses (`0`, `$`, `.`, line numbers), relative addresses (`+N`, `-N`), range syntax (`1,2m$`), and mark addresses (`'a`). The fork's `parseLineSpec_` handles the source range parsing; the plugin's `parseLineTarget` handles the destination address. Newline handling at document boundaries (inserting at position 0 or after the last line, moving the last line) is now correct. **Test coverage**: `test/specs/vim-builtin/ex-move-copy-normal.e2e.ts` — strengthened from crash-guard-only tests to behavioral assertions: `:m0`, `:m$`, `:m-2`, `:m3`, `:1,2m$`, `:t0`, `:t$`, `:1,2t$`. ## Select mode and Virtual Replace mode - Select mode: `selectmode=mouse` does not work — permanent platform limitation. CM6 does not expose the low-level mouse event API needed to intercept mouse-initiated selections and convert them to select mode. `:smap`/`:sunmap` fallback to `:vmap` when no select-specific mapping exists (matches Neovim). `selectmode=key` and `keymodel=startsel` options are accepted but shifted cursor key behavior is not functional. - Virtual Replace: TAB virtual-column handling is basic — East Asian Width (double-width CJK characters) is not yet accounted for in column width calculation. Newline handling in vreplace mode is simplified; `gR` does not delete the rest of the line (falls through to CM6 default). - Mode indicators for select, v-replace, command, search, and insert-normal require fork mode (built-in vim mode OFF). - Operator-pending mode indicator is not shown (too transient to be useful in the status bar). | `dG` | Deletes from cursor to end of file, no trailing newline | Fixed in fork | The fork's `operators.delete` now expands the anchor to include the preceding newline when deleting linewise to end of file. | | `>>` | Cursor at first non-blank after indent | Fixed in fork | The fork's `operators.indent` now returns cursor at column 0, matching Neovim behavior. | | `V` + `>` | Cursor at first non-blank after visual indent | Fixed in fork | Same fix as `>>` — cursor at column 0 after indent. | | `d0` | No-op at column 0 (zero-width motion) | Fixed in fork | Zero-width exclusive range produces no-op as expected. | | `<<` | Unindent by shiftwidth spaces | Fixed in fork | Fork's indent operator now reads `getOption('shiftwidth')` and `getOption('expandtab')`, falling back to CM6's `tabSize`/`indentWithTabs` when the vim options are not defined. | | `dd` | Cursor stays at same column | Fixed in fork | Fork preserves cursor column after linewise delete instead of moving to first non-blank. | | `J` | Strips trailing whitespace before join | Fixed in fork | Fork strips trailing whitespace from current line before adding join space, preventing double spaces. | | `di{` multiline | Preserves bracket lines (`a{\n}b`) | Fixed in fork | Fork deletes inner content lines only, keeping opening/closing bracket on their own lines. | | `dj`/`dk` boundary | No-op at document start/end | Fixed in fork | Fork returns null from `moveByLines` when `j`/`k` can't move to a different line. | | `:s` cursor | First non-blank of last affected line | Fixed in fork | Fork's `doReplace` positions cursor at first non-blank instead of column 0. | | `%` + strings | Skips brackets in string/comment tokens | Fixed in fork (string-aware `scanForBracket`) | Fork's `moveToMatchedSymbol` aborts when the first bracket is in a string, and `scanForBracket` now skips brackets in string/comment tokens during matching. In Markdown, Lezer does not classify double-quoted text as string tokens, so the `(a")"b)` test case remains a deviation in Markdown context only. | | `db` cross-line | Includes leading whitespace when crossing lines | Fixed in fork | Fork expands delete range to include whitespace-only prefix before cursor when delete crosses a line boundary. | | `da"` whitespace | Deletes quotes and adjacent whitespace | Fixed in fork | Fork's `findBeginningAndEnd` now consumes trailing whitespace (or leading if no trailing) after inclusive quote expansion, matching Neovim's `a"` behavior. | | `:join` cursor | Cursor at column 0 of joined line | Fixed in fork | Fork's ex command handler sets cursor to `(line, 0)` after join. | | `:global` cursor | Cursor at last matched line after `:g/pattern/d` | Fixed in fork | Fork sets cursor to last matched line (clamped to document end) after line-deleting `:g` commands. Non-destructive `:g` leaves cursor where the last sub-command placed it. | | `:s` empty | Repeats last pattern with default flags (no `/g`) | Fixed in fork | Fork's `:s` without arguments no longer preserves the `/g` flag from the previous substitution. | | `gj`/`gk` widgets | Navigates into replaced decorations | Fixed in fork | Fork's `findPosV` clamps any multi-document-line jump to ±1 when no fold is present. This handles both replaced widgets (MathJax) and variable-height lines (headings with larger fonts). `posAtCoords` resolves the horizontal position on the clamped target line. | | `gj`/`gk` column | Preserves character column across lines | Pixel drift | Neovim preserves the character column (`curswant`) because all terminal characters are monospace. The fork preserves the pixel X coordinate (`goalColumn`) via `posAtCoords`, which maps to a different character index on heading lines (wider font). The round-trip (`gk gk gj gj`) returns to the exact starting column because the pixel X is preserved throughout. See "gk/gj column drift on heading lines" below. | | `gk` frontmatter | Navigates into frontmatter like `k` | Fixed in fork | Fork's `moveByDisplayLines` now checks `focusBefore` on the `findPosV` result, matching the existing check in `moveByLines`. The `stuckAtBoundary` condition uses `range.head === startOffset` to avoid false positives on wrapped lines — `gk` navigates wrapped display lines first and only enters properties from the topmost display line. Users who remap `k` to `gk` can now enter frontmatter navigation. | ## Surround nvim-surround parity gaps **Status**: 74 golden comparison tests against [nvim-surround](https://github.com/kylechui/nvim-surround) (Neovim 0.12.2). **74 pass.** The ground truth was shifted from tpope/vim-surround to nvim-surround — nvim-surround is better maintained, has a comprehensive test suite, and is Lua-native (aligned with Neovim's direction). It implements all tpope/vim-surround behavior plus extensions. **Fixed in this release**: - Opening bracket `ds(`/`ds[`/`ds{` now works — `findSurroundingBrackets` parameter swap fixed - Cursor position after `ys`/`yss`/visual `S` now at `ch:0` (on the delimiter) — matching nvim-surround - `ds(` on nested parens and multiline content now works - `cs({` now correctly finds and changes parens to braces with spaces - `ds}` space preservation — closing-bracket forms now preserve inner spaces (opening forms still strip) - `cs` chained operations — `_surroundReplacement` no longer leaks between different surround operation types - `cs` dot-repeat — `csba..` correctly changes nested bracket layers via search position offset. The offset was later found to be applied one iteration too early. It exists so a second or later pass through the count loop starts past the delimiter the previous pass wrote and expands outward; applied to the first pass it only moves the search off the cursor, so a repeat with the cursor on the last character before a closing quote matched that quote against the following pair's opening quote and produced `(test), "test(, )test", "test"` where `(test), (test), "test", "test"` was wanted. It now applies only when the cursor is standing on that delimiter — where `changeSurroundPair` parks it — so `csba..` still walks outward through `(((test)))` while a cursor resting inside a pair is left alone. ([#197](https://github.com/saberzero1/motions/issues/197)) - Multiline `dsb` — cursor clamped to valid line length after bracket deletion - Count-prefixed `ds`/`cs` — now uses "apply N times" semantics matching nvim-surround (`2dsb` = delete twice, `3csbr` = change all 3 levels) - `ys` with line-crossing motions — `ysjb`, `ys2jB` correctly expand to full lines for linewise motions - `ySS`/`VSB` newline indentation — single-line content no longer gets extra 2-space indent, matching nvim-surround - Visual block `$ S}` — now surrounds each line individually instead of wrapping entire block - `dsf` (delete surrounding function call) — implemented with regex-based function name detection - `csbBysaBb` chain — `ys` with text object motions (`aB`, `iw`) after `cs` now works. The `ys_motion` handler directly evaluates text object motions instead of dispatching through the fragile `handleKey` → `evalInput` path where `clearInputState` would lose the `selectedCharacter`. - `ys` with the plugin's Markdown text objects — `ysi$`, `ysa$`, `ysi=`, `ysi~`, `ysi_`, `ysil`, `ysiC`, `ysio`, `ysi,` and the rest now work. The `ys_motion` handler previously called the fork's built-in text object function directly, which only knows the built-in objects (``( ) { } [ ] < > ' " ` b B w W p t s``); anything else cancelled the operation and cleared the pending `ysi` from the chord display. Resolution now matches normal operator-pending — the exact key sequence is looked up in the keymap first, with the built-in object as a fallback, so a registered object that shadows a built-in one (`aB` is the blockquote object, not the `{}` block) only wins where it actually matches. ([#179](https://github.com/saberzero1/motions/issues/179)) **Remaining deviations** (3 cases): | Category | Count | Description | | --------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ys` dot-repeat with tag/function | 2 | `ysiw` 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): - ~~`ys` dot-repeat with text objects~~ — Fixed for simple delimiters (`ysiwb`, `ysiw"`, `ysaw'`, `ysiw]`). Fork stores text object motion characters in `lastEditInputState._ysTextObjectMotion` and `_ysTextObjectChar` via the `onRepeat` callback. During dot-repeat, `repeatLastEdit` re-evaluates the text object at the current cursor position and applies `addSurroundToRange()`. Fork tests: 1806/0 (was 1803/3). - ~~Tag `cst`/`yst` (change/add tag)~~ — Verified working. Fork tests (`vim_cst_to_tag`, `vim_cst_to_char`, `vim_ysiw_tag`, `vim_dot_cst`) and plugin e2e tests (74 golden + 81 plugin-level) all pass. The original golden data was recorded against vanilla Neovim (no nvim-surround plugin), making golden comparison meaningless for surround. Plugin e2e tests are the definitive verification. **Test coverage**: `test/specs/vim-builtin/surround-golden.e2e.ts` — 74 golden tests. `test/specs/surround.e2e.ts` — 98 passing, 2 skipped (tag/function dot-repeat — verified at fork level). Fork: 238 surround cases pass; the full fork suite is 1915 passing with one pre-existing failure unrelated to surround (`vim_increment_octal`, which fails identically on pristine `master`). **Built-in pairs are overridable.** All 19 built-in surround characters used to be refused by `registerSurroundPair`; they are now rebindable from Lua and vimrc, including the three that carry interactive behaviour (`t` and `f` as targets, `<` as a replacement). An alias resolves to its canonical character first, so overriding `)` reaches `b`. Empty delimiters are rejected, because they would disable the character rather than rebind it. ([#197](https://github.com/saberzero1/motions/issues/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](https://github.com/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 (`` + `gr`)~~ — Implemented blockwise replacement with register line duplication/truncation and per-line replacements. **Test coverage**: `test/specs/operators.e2e.ts` — 26 passing tests: `grr` (single, multi-line, count), `griw`, `gr$`, `grl`, `gri'`, `gr}`, named registers (`"agriw`, `"a3grr`), visual `gr` (charwise, linewise `V`, blockwise ``), register type coercion (linewise↔charwise), cursor positioning, dot-repeat (`griw`, `grr`, `3grr`+`.`), multi-line register expansion, text object at line boundary. ## Test-discovered behavioral discrepancies These were found by translating edge-case tests from Neovim's legacy test suite and replit/codemirror-vim. Each has a corresponding `it.skip()` test with a `// BUG:` comment. ### `dG` leaves trailing newline **Status**: Fixed in fork. **Test**: `test/specs/vim-builtin/operator-combos.e2e.ts` — "dG should delete from current line to end of file" `dG` from line 2 of a 4-line document produces `'one'` instead of `'one\n'`. The fork's `operators.delete` now expands the anchor to include the preceding newline when deleting linewise to end of file. ### ~~`iB` does not scope to innermost blockquote nesting level~~ **Status**: Fixed. Both tree-backed and fallback blockquote text objects select contiguous lines at or above the cursor line's explicit quote depth. Tree-backed selections exclude CommonMark lazy continuations at lower depth, strip the full nested prefix for inner selections, and preserve the fallback's newline handling for around selections. ### ~~`di*` operates when cursor is on the delimiter~~ **Status**: Fixed. Both tree-backed and fallback inner delimiter objects exclude cursor positions on the delimiter characters; around objects still include them. Tree-backed matching validates the exact delimiter character and run length at both ends and walks outward past incompatible nodes, including the grammar's nested single-tilde node inside `~~text~~`. ### ~~Dot-repeat of `cw` + typed text unreliable~~ (Fixed) The vim engine correctly records and replays insert mode changes after `cw`. The original test failure was caused by using `browser.keys` (DOM events) for insert mode typing instead of `vimRawKeys`, which dispatches keys through the Vim key handler. ### ~~`)` sentence motion cursor position at end of text~~ (Fixed) Fixed in fork. The `findSentence()` forward scan now checks whether the computed fallback position is at or before the starting cursor on the same line, and returns the original position unchanged if so. ### ~~`n`/`N` search wrap-around unreliable~~ (Fixed) The vim engine correctly wraps search results. The original test failure was caused by using individual `browser.keys` calls with pauses for the `/foo` + Enter + `n` sequence instead of `vimRawKeys`, which dispatches the full key sequence through the Vim key handler without timing gaps. ## Hint mode in the separate settings window (Obsidian 1.13+) **Status**: Platform limitation. In Obsidian 1.13+, the settings window opens as a separate OS-level Electron BrowserWindow by default. This window runs in its own renderer process, which plugin JavaScript in the main window cannot access. The plugin's global hotkey listener and hint mode overlay cannot be injected into this window. **Workaround**: Disable the separate settings window by unchecking **Settings → Interface → Open settings in new window**. When settings opens as an in-app modal instead, the plugin's global hotkey and hint mode work normally — the capture-phase keyboard listener fires before the modal's scope intercepts events. Hint mode works in all other contexts: the main window, workspace popout windows (popped-out notes), and any in-app modal (command palette, file switcher, etc.). ## Hint mode element selector fragility Hint mode targets clickable elements using CSS class selectors like `.nav-file-title`, `.workspace-tab-header`, `.vertical-tab-nav-item`, etc. These are Obsidian's internal CSS classes, not part of the public plugin API. They may change between Obsidian versions. Standard HTML selectors (`a[href]`, `button`, `[role="button"]`, etc.) are stable. If hint mode stops labeling certain UI elements after an Obsidian update, the selector list in `src/ui/hint-mode.ts` may need updating. ## Status bar left-alignment The vim mode indicator and chord display are positioned at the leftmost edge of the status bar via `parentElement.insertBefore(el, firstChild)` and `margin-right: auto`. This relies on Obsidian's status bar being a CSS flexbox container with `justify-content: flex-end` — if Obsidian changes its status bar layout in a future version, the positioning may break. The powerline `::after` pseudo-element (CSS border-triangle) also depends on the status bar's flex item sizing. ## ~~Obsidian native highlights not cleared on Escape~~ (Fixed) **Status**: Fixed. Pressing Escape in normal mode now clears Obsidian's `is-flashing` highlights (the highlight shown after following an internal link to a heading like `[[Note#heading]]`). Uses the unofficial `editor.removeHighlights('is-flashing')` API. ([#122](https://github.com/saberzero1/motions/issues/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 `` 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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/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 (`s`/`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](https://github.com/saberzero1/motions/issues/36)). **Breaking changes from CM Vim defaults**: - `<` in replacement position triggers tag prompting (was angle brackets with spaces). Use `>` for no-space angle brackets. - `f`/`F` in replacement position triggers function wrapping (was literal `f`/`F` as delimiters). - `S` in visual mode now surrounds instead of substituting (was `S` → `VdO` keyToKey). ## Lua configuration (`init.lua`) **Status**: Working. Sandboxed Lua 5.3 runtime via a browser-only version of fengari, absorbed into the monorepo at `src/lib/fengari/` and converted to TypeScript ESM (originally based on [this fork](https://github.com/saberzero1/fengari)). ([#46](https://github.com/saberzero1/motions/issues/46)) The plugin supports Lua config files (`init.lua`, `.init.lua`, etc. — see [Config file resolution](#config-file-resolution)) as an alternative to vimrc. Enable in **Settings → Vim Motions → Vimrc & key bindings → Configuration mode**. ### Supported APIs The Lua config runtime (`init.lua`) supports `vim.opt` (including `guicursor`), `vim.o`, `vim.g` (including `mode_prompt_*`), `vim.keymap.set`, `vim.keymap.del`, `vim.cmd()`, `vim.vault_name()`, `vim.tbl_*`, `vim.split`, `vim.trim`, `vim.startswith`, `vim.endswith`, `vim.stricmp`, `vim.inspect`, `vim.json`, `vim.schedule`, `vim.defer_fn`, `vim.uv`, `vim.notify` (with levels), `vim.obsidian`/`vim.ob` (including `vim.ob.meta.*` (9 functions), `vim.ob.fs.*` (11 functions), `vim.ob.ui.*` (4 functions), `vim.ob.im.*` (4 functions + 2 properties), `vim.ob.get_cursor`, `vim.ob.set_cursor`, `vim.ob.get_selection`, `vim.ob.mode`, `vim.ob.notice`, `vim.obsidian.keymap.set/del` for global keymaps, `vim.obsidian.whichkey.set_group/set_label/add` for which-key labels, `vim.obsidian.cursor.set` for cursor shapes, `vim.obsidian.modeprompt.set` for mode prompts, `vim.obsidian.surround.set/del/add` for custom surround pairs, `vim.obsidian.leader.set/del/add` for leader bindings, and `vim.obsidian.pick(source, opts?)` for the fuzzy picker), `vim.env`, `vim.api.nvim_set_hl`, `vim.api.nvim_buf_*`, and `print()`. See `docs/configuration/lua-config.md` for the full reference. ### Unsupported Neovim APIs `require()`, `vim.lsp`, `vim.treesitter`, `vim.ui`, `vim.diagnostic`: accessing these produces a clear error message. `vim.api` is partially supported: `nvim_create_user_command`, `nvim_create_autocmd`, `nvim_create_augroup`, `nvim_del_autocmd`, `nvim_del_augroup_by_name`, `nvim_clear_autocmds`, `nvim_set_hl`, `nvim_get_hl`, `nvim_create_namespace`, `nvim_buf_get_lines`, `nvim_buf_set_lines`, `nvim_get_current_buf`, `nvim_buf_get_name`, `nvim_buf_line_count`, `nvim_buf_set_keymap`, and `nvim_buf_del_keymap` are available; other `vim.fn` is partially supported (see below): unsupported `vim.fn.*` functions produce a helpful error listing available functions. The Lua runtime is sandboxed: 7 standard libraries are loaded (`_G`, `string`, `table`, `math`, `coroutine`, `utf8`, `os`). The `io`, `debug`, and `package` libraries are not available. `os.execute` and `os.exit` are permanently blocked. Global functions `load`, `dofile`, `loadfile`, `require`, `rawget`, `rawset`, and `rawequal` are disabled. ### Autocmds 19 events supported: `InsertEnter`, `InsertLeave`, `ModeChanged`, `BufEnter`, `BufLeave`, `BufWritePre`, `BufWritePost`, `FocusGained`, `FocusLost`, `TextYankPost`, `CursorMoved`, `CursorHold`, `LeafEnter`, `LeafLeave`, `FileType`, `OilEnter`, `OilLeave`, `CmdlineEnter`, `CmdlineLeave`. See `docs/configuration/lua-config.md` for the full reference. Limitations: - All autocmds are non-nested (callbacks cannot trigger other autocmds) - `buffer` option not supported (Obsidian has no buffer numbers) - `command` option not supported (use `callback` only) - `nested` option not supported - `buf` field in event data is always 0 - `TextYankPost` requires bundled fork mode (built-in vim mode OFF). It also does not fire while the Neovim backend owns keys, for the same reason the plugin's own Lua keymaps do not: the event comes from the bundled engine, which is stood down. The **yank highlight** feature is unaffected — under the backend it is driven by a Neovim `TextYankPost` notification instead. ### Per-view mode events ([#88](https://github.com/saberzero1/motions/issues/88)) Mode events (`InsertEnter`, `InsertLeave`, `ModeChanged`) fire per-view across all editors — split panes, popover hover-preview editors, and canvas card text inputs — when using the bundled vim fork (recommended setup with built-in vim mode OFF). Built-in vim mode retains active-leaf-only behavior for these events. ~~Other adapter-dependent events (`TextYankPost`, `CursorMoved`, `CursorHold`, `CmdlineEnter`, `CmdlineLeave`) are still active-leaf-only~~. Fixed. All 5 events now fire per-view via `AutocmdEventWatcher` CM6 ViewPlugin, following the same pattern as `AutocmdModeWatcher`. `CursorMoved` fires independently per view with position-change detection. `CursorHold` uses a per-view timer with configurable delay. `CmdlineEnter`/`CmdlineLeave` route through the per-view watcher but inherently fire on the active adapter only (the dialog opens on the focused editor). `getModeState()` returns global state reflecting the most recent mode event from any view, not per-view state. `vim.obsidian.mode()` reads the active leaf's mode, not the event source's mode — if a popover fires `InsertEnter`, `vim.obsidian.mode()` may still return `'n'` if the active leaf is in normal mode. ### Lua dialect: 5.3 here, LuaJIT (5.1) in Neovim Neovim documents Lua 5.1 as its permanent plugin interface; this runtime is fengari, a Lua 5.3 VM. Where 5.3 offers more than 5.1 (`utf8`, `string.pack`, an integer subtype, `__gc` on tables) the difference is harmless — plugins simply do not use it. What breaks plugins is the reverse: things LuaJIT has that 5.3 removed. Shimmed in `src/lua/engine.ts`: the global `unpack`, `loadstring`, `string.gfind`, `table.maxn`/`getn`, `coroutine.isyieldable`, a partial `jit` table, and the `bit` library. `getfenv`/`setfenv` remain stubs — `getfenv` returns `_G` and `setfenv` does nothing. They cannot be emulated faithfully in 5.3, which replaced the function-environment model with `_ENV`. A plugin relying on `setfenv` to sandbox a chunk will silently not be sandboxed. ### LuaJIT FFI is not available Neovim ships LuaJIT, so `require("ffi")` works there and plugin authors use it freely to reach internal C symbols the API does not expose. This runtime is fengari, a pure-Lua VM, so there is no FFI to provide and no way to implement one. `require("ffi")` and `require("jit")` fail with a message naming LuaJIT rather than reporting a file-read failure. In practice FFI use is rare and concentrated in "reach past the API" corners — flash.nvim quarantines its uses in `hacks.lua`. Where a plugin guards the call, it degrades. Where it does not, that code path is unavailable. flash's `get_end_pos` is an example: `searchpos()` reports where a match starts, and flash reads Neovim's internal `search_match_endcol` to find where it ends. ### Decoration provider is a coalesced approximation `nvim_set_decoration_provider` supports `on_start`, `on_buf`, `on_win`, and `on_end`. `on_line` and `on_range` are **not** implemented and raise a Lua error at registration — CodeMirror has no per-visible-line redraw callback, and silently accepting them would let a plugin believe its per-line decorations were drawn. `ephemeral = true` extmarks raise an error for the same reason: without a real redraw cycle they would persist, accumulating stale decorations. The cycle runs once per animation frame after a document, viewport, geometry, selection, or focus change, so it lags Neovim by up to one frame. It runs only in bundled fork mode; with Obsidian's built-in vim mode the whole Lua CodeMirror extension set (including extmarks) is not registered. Providers see a single window and buffer, both handle `0`. ### `vim.ui_attach` is not implemented `vim.ui.select`, `input`, `open`, and `progress_status` are available. `vim.ui_attach`/`vim.ui_detach` are not, and are deliberately absent rather than stubbed. `ext_messages` is an ownership _transfer_, not a subscription: a UI that attaches takes over rendering, and Neovim stops. A partial event stream would therefore cause a consumer such as noice.nvim to suppress real notifications and render nothing in their place — invisible message loss, which is worse than the feature being missing. Even a complete stream would not deliver noice, which renders into floating windows that remain stubs. `vim.ui.open` is desktop-only; on mobile it returns `nil, errmsg`, which is Neovim's own no-handler shape. `opts.cmd` is rejected. ### Extmark priority does not control visual precedence `nvim_buf_set_extmark`'s `priority` orders overlapping decorations deterministically, but does not decide which one is rendered innermost. CodeMirror 6 mark decorations have no z-index, and `Decoration.set(ranges, true)` re-sorts the input, so CM6's comparator has the final say on nesting. Two overlapping `hl_group` marks therefore render in a stable, priority-derived order, but the higher-priority group is not guaranteed to be the visible one. Plugins that layer highlights (flash.nvim layers backdrop / match / label / cursor) may show the wrong colour on overlap. `sign_text`, `conceal`, `hl_mode`, `virt_lines`, and `url` are modelled or absent and are not populated from Lua. ### `vim.fn.*` subset 92 Neovim `vim.fn.*` functions have real handlers with all async callbacks (89 without them); 39 registered stubs remain. See `docs/configuration/lua-config.md` for the full list and `NEOVIM_API_STATUS.md` for source-guarded counts. Known stubs return placeholders or intentionally reject; unknown names raise on read. No `ABSENT_NVIM_API_FUNCTIONS` tier exists; deliberately absent fields in plain namespaces read nil. Editor-state queries need an active editor. ### `vim.fn.strwidth` is not display width `src/lua/fn.ts` still returns `s.length` from `strwidth`: JavaScript UTF-16 code units, not screen cells. This is a separate quarantined display-width defect, not fixed by the coordinate work. Do not use it as a coordinate conversion or test oracle; tabs, wide characters and composing marks invalidate that assumption. ### Silent string and URI placeholders The pre-work `src/lua/stdlib.ts:1055-1111` audit found twelve silent placeholders. Five now have real coordinate handlers: `vim.str_byteindex`, `vim.str_utfindex`, `vim.str_utf_start`, `vim.str_utf_end`, `vim.str_utf_pos`. Seven remain: `vim.iconv`, `vim.uri_decode`, `vim.uri_encode`, `vim.uri_from_bufnr`, `vim.uri_from_fname`, `vim.uri_to_bufnr`, `vim.uri_to_fname`. They return identities/constants without warning. This distinct **silent** failure mode is worse to diagnose than a warn-once stub because there is no console trace. ### Audited third-party plugins remain blocked Phase 5/5b's demand audit leaves both suites **BLOCKED**; integration Phases 6/7 are cancelled and move to a follow-up plan. These are not passed suites or compatibility claims: - **mini.surround:** no load blockers; core `surround-highlight`, `echospace`, `getchar-context`, `input-context-and-form`. - **mini.splitjoin:** one load blocker, `string-expr-mapping`, plus core `local-comments`. String expression mappings require Vimscript evaluation, which this host does not have. This is an **architectural constraint**, not a missing function or a to-do item; it may never be unblockable in this host. - **flash.nvim:** terminal LuaJIT FFI blocker, `module 'ffi' is not available`, proven by `test/specs/lua-plugin-flash-diagnostic.e2e.ts`. Floating-window or decoration-provider API work cannot unblock it. The fork's built-in surround feature is separate from mini.surround. `test/fixtures/mini-api-demand.json` records the pinned audit; `plugin-api-demand.test.ts` accounts for Phase 5b's five measured promotions. Phases 1–3 removed exactly core `set-text-bytes`, `getpos-bytes`, `extmark-columns` and optional `get-text-bytes`; non-coordinate blockers remain. mini.comment's existing operation tests now use immutable ref `27a29d6b949b9497f80a0a03421e89fed71d8c37` in `test/fixtures/test-plugins.json`, pinned in Phase 0. This improves reproducibility, not the breadth of the compatibility claim. ### Hybrid loading Settings (`vim.opt`) and keymaps (`vim.keymap.set`) load immediately without an active editor. `vim.cmd()` calls at load time are queued and executed when the first editor receives focus. `vim.cmd()` calls from runtime contexts (function-mapped keymaps, autocmd callbacks, timer callbacks, user commands) execute immediately against the active editor. If no editor is active when a runtime `vim.cmd()` fires, the command is skipped with a console warning. If no init.lua file exists, the loader silently skips (no notice). ### Loading order init.lua loads after vimrc. Both can be used simultaneously — Lua values override vimrc values on conflict. This differs from Neovim, which uses either `init.lua` or `.vimrc`, not both. ### Function callbacks and Tier 3 functions Lua function callbacks (`vim.keymap.set('n', 'key', function() ... end)`) execute at keypress time, not config-load time. `vim.cmd()`, `vim.fn.line('.')`, `vim.fn.col('.')`, and other editor-state-dependent functions work correctly inside callbacks. They error at config-load time because no editor is active (context-aware execution). Leader-prefixed keymaps registered via `vim.keymap.set` with a `desc` option automatically appear in the which-key overlay. ### ~~`vim.cmd()` broken at runtime~~ (Fixed) `vim.cmd()` called from runtime contexts (function-mapped keymaps, autocmd callbacks, timer callbacks, user commands) silently failed. The `handleExCommand` callback pushed commands to a `pendingExCommands` queue that was drained once after initial load — runtime calls pushed to an orphaned array. Fixed by adding a `runtimeExHandler` that executes commands immediately via `vim.handleEx()` after load completes. Cleanup on plugin unload prevents stale callbacks. ([#49](https://github.com/saberzero1/motions/issues/49), [#27](https://github.com/saberzero1/motions/issues/27)) ### ~~`vim.keymap.set` leader bindings not in which-key~~ (Fixed) `vim.keymap.set("n", "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](https://github.com/saberzero1/motions/issues/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 `''`, matching the codemirror-vim `vimKeyFromEvent` output. No fork-side changes are required for this behavior. ### `executeLuaForTest` does not support runtime `vim.cmd()` The test-only Lua executor (`executeLuaForTest` in main.ts) has `handleExCommand: () => {}` (no-op). `vim.cmd()` calls through this path silently do nothing. It also lacks `onLeaderBinding` and runtime handler activation. Use `loadLuaConfig()` (via `loadLuaConfigForTest`) for tests that need runtime Lua behavior. ### ~~No Lua instruction-count hook on runtime callbacks~~ (Fixed) All runtime `lua_pcall` sites (function keymaps, user commands, autocmd handlers, timer callbacks, snippet dynamic nodes) are now wrapped with `withInstructionGuard`, which sets `lua_sethook` with `LUA_MASKCOUNT` before each call and clears it after. The instruction limit is 500,000 for callbacks and 100,000 for snippet nodes. On timeout, a throttled `Notice` is shown (5-second cooldown to prevent spam) and the error is logged. Obsidian remains responsive. ### Known deviations from Neovim 4 deviations registered in `test/neovim/deviations.ts`: - ~~`keymap.del` + `Q`: plugin's built-in `Q→@@` mapping persists after Lua unmap~~ — Fixed. `reloadFeatures()` now re-applies Lua map operations (including unmaps) after re-registering built-in mappings - `cw` + `` 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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/93)) ### ~~Hidden files toggle (`g.`) has no effect~~ (Fixed) **Status**: Fixed. The `??` operator on the boolean-typed `oilShowHiddenFiles` setting never fell through to the runtime toggle. Replaced with a `showHiddenOverride: boolean | null` field that takes priority when set by `g.`. ([#93](https://github.com/saberzero1/motions/issues/93)) ### ~~`` opens file in new tab instead of same leaf~~ (Fixed) **Status**: Fixed. `openEntryAtCursor()` now uses `leaf.openFile()` directly on the Oil leaf to replace the Oil view, matching oil.nvim's default `select` behavior. `` is available for opening in a new tab. ([#93](https://github.com/saberzero1/motions/issues/93)) ### ~~``/``/`` keybindings do nothing~~ (Fixed) **Status**: Fixed. Obsidian's default hotkeys (`Ctrl+T` = new tab, `Ctrl+S` = save, `Ctrl+H` = search & replace) intercepted these keys at the Electron level before the embeddable editor's vim handler received them. Fixed by registering these keys (plus `` and ``) 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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/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](https://github.com/stevearc/oil.nvim)'s features. The following oil.nvim behaviors are not yet implemented: - **Brace expansion**: oil.nvim supports `foo.{js,test.js}` syntax to create multiple files at once. The plugin requires one file per line. - **Copy via buffer edit**: In oil.nvim, duplicating an existing entry line (same name, new ID) triggers a file copy. The plugin does not support this; use Obsidian's file explorer for copies. - **Trash view toggle** (`g\`): oil.nvim can toggle between the file view and a trash view for the current directory. Not applicable — Obsidian manages trash separately. - **Incremental rendering**: oil.nvim renders large directories progressively (25ms/500ms thresholds). The plugin renders all entries at once, which may cause a brief delay for directories with thousands of files. - **`../` entry**: oil.nvim shows a `../` entry as the first line for navigating up. The plugin uses the `-` keybinding instead. - **Column display**: oil.nvim supports configurable columns (icon, size, permissions, mtime). The plugin shows emoji icons (📁/📄) only. - **LSP workspace edit integration**: oil.nvim fires `willRenameFiles`/`willCreateFiles`/`willDeleteFiles` for LSP clients. The plugin does not integrate with LSP workspace edits. - **`_` (open cwd)**, **`` ` `` (`:cd`)**, **`g~` (`:tcd`)**: These oil.nvim bindings relate to Neovim's current working directory concept, which has no equivalent in Obsidian. ### ~~Note freezes in Reading Mode after closing Oil~~ (Fixed) **Status**: Fixed. Closing Oil now restores the original editor mode (source, live preview, or reading). The mode is captured when Oil opens via `MarkdownView.getState()` and restored via `leaf.openFile(file, { state: previousViewMode })` on close. All close paths (keybindings, ex commands, Lua API) use a unified `closeOil()` method. ([#93](https://github.com/saberzero1/motions/issues/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](https://github.com/saberzero1/motions/issues/93)) ### ~~`:Oil .` opens current file's directory instead of vault root~~ (Fixed) **Status**: Fixed. `:Oil .` and `:Oil /` now correctly open the vault root. The empty-argument case (`:Oil` with no args) still opens the current file's parent directory. Both the vim ex command handler and the global ex command palette are updated. ([#93](https://github.com/saberzero1/motions/issues/93)) ### ~~Oil temp files visible with `oil~` prefix~~ (Fixed) **Status**: Fixed. Oil now uses a dedicated view type with an embedded editor. No temporary files are created in the vault. ### ~~Dotfiles cannot be used for temp files~~ (Fixed) **Status**: Fixed. No longer relevant — Oil no longer creates any files in the vault. ### ~~Keybindings are not user-remappable~~ (Implemented) **Status**: Implemented. All keybindings across all contexts are user-remappable. Every keybinding is remappable through one of four mechanisms depending on context: - **Editor keybindings** (motions, actions, operators): All have ex command aliases (e.g., `:nextheading`, `:focuspaneleft`, `:tablenextcell`, `:hintactivate`). Remap via `vim.keymap.set('n', 'key', ':excommand')` in Lua or `nmap key :excommand` 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 (`` same-leaf, `` new tab, `` vertical split, `` horizontal split, `` preview toggle, ``/`q` close, `gx` open external). Visual mode multi-select (`V` + `` opens all selected files). Buffer-local remapping via `OilEnter`/`OilLeave` autocmd events. - **Picker keybindings**: Configurable via `vim.obsidian.pick_keymap()` in Lua. Not available via vimrc (picker operates outside the vim keymap system). - **Global workspace navigation**: Remappable via `vim.obsidian.keymap.set`/`del` (Lua) and `:gmap`/`:gunmap`/`:gmaps` (vimrc and ex command line). Each default is tagged with a stable name. See `docs/configuration/remapping.md` for the full remapping guide with examples for each context. **Remaining limitations**: - ~~Which-key integration for oil keybindings (showing oil bindings in the which-key popup) is planned but not yet implemented~~ — Fixed. Oil command labels are registered in the which-key overlay's `commandLabels` map. When oil bindings are dynamically mapped (on `OilEnter`), they appear in `vim.getCompletions()` and the which-key overlay displays descriptive labels instead of raw ex command strings. When leaving oil, bindings are unmapped and disappear from completions. - ~~Help command (`g?` in oil context) is planned but not yet implemented~~ — Implemented. `g?` opens a `VimInfoModal` listing all oil keybindings in a Key/Action table, following the same pattern used by `:marks`, `:buffers`, and `:registers` when the picker is disabled. Dismissible via Escape. ### ~~Which-key and `g?` in non-editor context~~ (Fixed) Investigation (spike25) found that `WhichKeyOverlay.tryAttach()` correctly attaches to Oil's embedded CM6 editor via `getCmAdapterFromEditorView()`, even when Oil is the only view (no prior MarkdownView). The which-key overlay works in Oil-only contexts. ### ~~Which-key "all" mode intercepts multi-key Oil bindings~~ (Fixed) When which-key mode is set to "All partial keys" and the popup delay is non-zero (default 500ms), pressing `g` in Oil started a delayed timer. The overlay appeared between the `g` and the second keystroke (`?`, `.`, `s`, `f`), disrupting the vim key sequence completion. Fixed by bypassing the popup delay timer when the active view is an OilView — the overlay shows immediately (matching delay=0 behavior), which allows the vim engine to process multi-key bindings (`g?`, `g.`, `gs`, `gf`) without interference. The overlay still appears for partial sequences in Oil, preserving discoverability for operator-pending keys (`d`, `c`, `y`, etc.). **Test coverage**: `test/specs/oil-which-key.e2e.ts` — 4 tests covering `g?` help modal, `g.` non-interception, no stale overlay after `g?`, and leader-mode control. | `vim.lsp.*` / `vim.treesitter.*` | Not applicable to Obsidian | | Async Lua (coroutine ↔ Promise bridge) | Implemented — `src/lua/coroutine-runner.ts` yields a Lua coroutine on an async host call and resumes it with the result, with a 10 s timeout and a 16-coroutine limit. `vim.schedule`, `vim.defer_fn` and the `vim.uv` timer subset are available. Snippet `f()`/`d()` nodes are deliberately blocked from async | ### ~~Vault file reading~~ (Implemented) `vim.ob.fs.read(path)` is now available in async-capable callback contexts (keymap callbacks, autocmd handlers, timer callbacks, user commands). The function yields the Lua coroutine internally and resumes when the vault read completes. `vim.ob.fs.readlines(path)` returns a table of lines. Both functions are catchable with `pcall`. Async APIs cannot be called from snippet `f()`/`d()` nodes or at the top level of `init.lua` (Phase 2). To read the current file's content synchronously, `vim.api.nvim_buf_get_lines(0, 0, -1, false)` remains available. **Test coverage**: 12 golden comparison tests (Neovim 0.12.2), 43 integration e2e tests covering settings, keymaps, error recovery (syntax/runtime/infinite loop), conditional config, coexistence with vimrc, disabled state, runtime `vim.cmd()` execution (8 tests), leader binding + which-key integration (9 tests), space-as-leader (7 tests), and documentation example validation (10 tests). ## Marks **Status**: Working. Gutter indicators, global mark persistence, grouped picker. Vim marks (`m{a-z}`, `'{a-z}`) work via codemirror-vim. The plugin adds three enhancements: - **Gutter indicators**: Mark letters appear in the gutter area next to marked lines using `Decoration.line()` with a `data-vim-marks` attribute and CSS `::after` pseudo-element. Zero layout shift — marks overlay the existing gutter without adding a column. Toggle via `enableMarkGutter` setting (default: on). - **Global mark persistence**: Marks `A`–`Z` are stored in plugin settings (`persistedMarks` array) with file path and cursor position. Saved via 30-second polling interval with dirty-flag check, plus immediate save on `onunload()`. Marks survive file closes and plugin restarts. - **Grouped marks picker**: `:marks` shows marks grouped under "Buffer marks" and "Global marks" headers. Global marks display the target file path. Cross-file navigation opens the target file and positions the cursor. ### Limitations - ~~**Special marks not in picker**~~ — Fixed. Special marks (`'`, `.`, `<`, `>`) are now shown in the `:marks` picker under a "Special marks" group. They are read from `cm.state.vim.marks` like buffer marks. - ~~**Global mark file rename**~~ — Fixed. `MarkStore.renamePath()` is called from the `vault.on('rename')` handler, and `MarkStore.removeByPath()` from `vault.on('delete')`, matching the existing harpoon and fold persistence patterns. - **Marks set outside vim command pipeline** — marks created programmatically (not via `m{char}`) won't trigger gutter refresh until the next vim command fires `vim-command-done`. - **Gutter refresh mechanism** — the gutter reads `cm.state.vim.marks` on each `vim-command-done` event. Position tracking through document edits uses `Decoration.line()` position mapping (`set.map(tr.changes)`), not polling. **Test coverage**: `test/specs/marks-gutter.e2e.ts` (6 tests), `test/specs/marks-picker.e2e.ts` (8 tests). ## Harpoon file pinning **Status**: Working. Pin files to numbered slots with cursor tracking and persistence. Pin files to numbered slots (`1`–`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 `` for tab-open. - **Cursor tracking is per-switch** — cursor position is captured when you leave a pinned file. Positions are not tracked continuously (no `CursorMoved` listener). If the plugin crashes, the last captured position may be stale by up to one editing session. - **Non-markdown cursor restore** — pinning works for any file type, but cursor position is only restored for `MarkdownView` files (PDFs, canvas, images don't have a text cursor). - **Sparse slot arrays** — removing a pin sets its slot to `null` without shifting other slots. Slot numbers are stable (pin 3 stays pin 3 even if pin 2 is removed). Trailing nulls are trimmed. ## Picker / Fuzzy finder **Status**: Working. Unified picker with 13 sources, preview pane, live grep, and frecency scoring. The picker uses a telescope.nvim-inspired visual presentation: monospace fonts, compact item density, accent-tinted selection, and floating border titles showing the source name (e.g. "Files"), "Results", and "Preview" on each section's top border. All colors use Obsidian CSS variables (`--font-monospace`, `--text-muted`, `--text-accent`, `--interactive-accent-hsl`, `--modal-background`, `--color-accent`) for full light/dark theme compatibility. The presentation matches the which-key overlay's terminal aesthetic. This Neovim-style visual language extends to all plugin modals: `SuggestModal` subclasses (`GlobalExCommandModal`, `OutlineModal`, `SearchResultsModal`, `ContextActionsModal`) use the prompt-modal pattern (transparent container, accent border, floating title, two-column suggestion rows with label + description), and `Modal` subclasses (`VimInfoModal`, `OilConfirmModal`) use the info-modal pattern (accent-bordered inner wrapper, floating title, hidden Obsidian chrome). The picker supports two fuzzy matching engines selectable via **Settings → Vim Motions → Picker matching engine**: - **uFuzzy** (default): Pure JavaScript matcher (7.5KB) with filename-aware ranking. Prefers exact filename matches over partial path matches (e.g., `Header.tsx` ranks above `header/utils.ts` for query `"Header"`). Supports typo tolerance via single-error mode, configurable fuzziness, and multi-word queries. - **obsidian**: Obsidian's built-in `prepareFuzzySearch` API. Zero bundle cost (maintained by Obsidian). May be slower than uFuzzy on very large vaults — the Obsidian docs note performance issues beyond a few thousand items. Matching is `RegExp`-based for grep (with fallback to substring matching for invalid patterns). Live grep debounces at 200ms with generation-based cancellation. ### Limitations - ~~**`:grep` is fuzzy, not regex**~~ — Fixed. `:grep` now uses JavaScript `RegExp` for pattern matching, matching Neovim's `:grep` behavior (which uses the external `grepprg`). Invalid regex patterns gracefully fall back to substring matching. - **`:marks` shows buffer + global marks** — buffer-local marks (`a`–`z`) are read from the active editor's vim state. Global marks (`A`–`Z`) are read from the plugin's persisted `MarkStore`. `:marks` in a non-editor view shows only global marks (no active editor for buffer marks). - **Live grep iterates all files synchronously** — `cachedRead()` is fast but iterating 10K+ files on each keystroke (debounced) may cause brief UI pauses on very large vaults. MAX_RESULTS=100 cap limits result set size. - **Frecency persistence** — frecency data is stored in plugin settings via `saveData()`, debounced to 30 seconds. Data loss on crash is possible for the last 30 seconds of interactions. - **Preview pane rendering** — full-file previews (files, buffers, recent) are rendered through `MarkdownRenderer.render()`, displaying headings, formatting, code blocks, images, and links with non-interactive links. Positional previews (grep, live grep, headings, marks) use monospace plain text with a line-number gutter that highlights the target line — raw text ensures uniform line heights so the gutter stays aligned (markdown rendering produces variable-height headings/blocks that cause drift). Frontmatter is excluded from positional previews since `MarkdownRenderer` strips it, which would otherwise misalign the gutter. The picker modal uses a fixed height (50vh) to prevent layout shifts. Plain-string previews (commands, registers) remain as raw text. - **Preview hidden on mobile** — `@media (max-width: 600px)` hides the preview pane entirely. - **Tags picker has no preview** — selecting a tag opens a sub-picker showing files with that tag. - **uFuzzy unicode mode** — adds ~2.5KB over the base library size for broader language support (CJK, Cyrillic, accented characters). ### Bundle size impact uFuzzy adds +17.5KB. Combined with picker UI code, the picker subsystem adds ~50KB to the production bundle. ## E2E test infrastructure weaknesses **Status**: Partially addressed. The e2e test suite had ~47 tests that did not reliably detect the regressions they claimed to guard against. A feature could be deleted or broken and these tests would still pass. **Fixed (this release)**: - **26 golden spec files** now have `else { throw }` guards on `SUITES.find()` — if a suite name is renamed in `test-definitions.ts` but not in the spec file, the test runner produces an explicit failure instead of silently generating zero tests - **Golden mode comparison** added to `testWithNeovim()` — golden data already contained `mode` values but the CI comparison path only checked `content` and `cursor`. Mode mismatches are now caught - **33 deviations classified** with a `category` field (`intentional`, `infra-limitation`, `upstream-bug`, `upstream-unsupported`, `recording-issue`). `findDeviation()` export added. `[INFRA-SKIP]` console warnings emitted for infra-limitation deviations so CI output shows how many tests are silently skipped due to infrastructure limitations - **8 undo-tree tests** strengthened with content assertions (previously only checked `mode === 'normal'`) - **4 ex-command tests** (`:undo`, `:redo`, `:yank`, `:nohlsearch`) strengthened with behavioral assertions. 16 workspace-layout ex-command tests renamed with `[crash-guard]` prefix to make their tier explicit - **6 vimrc mapping tests** strengthened with cursor movement verification (previously only called `assertPluginLoaded()`) - **1 tautological assertion** fixed (`toBeGreaterThanOrEqual(0)` → `toBe(0)` for undo tree branch count) **Remaining weaknesses**: - **15 deviation-masked operations effectively untested**: When `isKnownDeviation(name)` is true, `testWithNeovim()` skips ALL comparison. 15 remain as `infra-limitation` deviations: `gh`/`gH` select mode (6 tests — spike confirmed `handleKey` cannot enter select mode via `g`+`h` dispatch), `N after / search` (1 test — CM6 search panel timing), `` re-insert previous insert (1 test), `dgn`/`cgn` search-match operators (4 tests), `u` insert undo break (1 test), and `0`/`^` insert indent deletion (2 tests). Previously 10 — resolved 3: `V3j+J`, `vip+d`, `v+r`, `v+aw+d` fixed via `useHandleKey` flag + `vimHandleKeys` helper (dispatches all keys through `Vim.handleKey()` synchronously, bypassing DOM event timing); `lua nmap change word` fixed via key-string encoding (`` literal → `\x1b` byte); `lua leader key mapping` reclassified to `upstream-bug` (leaderRegistry propagation timing, not test dispatch); `vt.+d` and `v$+d` exposed as genuine `upstream-bug` behavioral deviations (visual-mode `t` range and `v$d` cursor position differ from Neovim) - **3 vimrc `set` option tests** (lines 109, 116, 121 in `vimrc.e2e.ts`) remain as `assertPluginLoaded()`-only. Root cause confirmed: the vimrc I/O timing issue (documented under [set textwidth via vimrc](#set-textwidth-via-vimrc-may-not-affect-gq)). `SideEffectOpt` options (`clipboard`, `expandtab`, `textwidth`) are applied via `onSettingOverride` → `applySettingOverride` → `this.settings[key] = value`, but `saveSettings()` strips these overrides using `preVimrcSettings`, and the `initializing` flag during `onload()` gates the override chain. By the time the test reads `plugin.settings.clipboard`, it returns `''` (the pre-vimrc default). `Vim.getOption('clipboard')` also returns `''`. The `vimrcOverrides` Map is empty by test time. The Lua `vim.opt` equivalent is verified and passing in `lua-config.e2e.ts` — the Lua path works because `loadLuaConfigForTest()` runs the override chain synchronously without the `initializing`/`saveSettings` stripping - ~~**`:changes` ex command test** remains a crash-guard. The correct modal selector is `.vim-motions-info-modal` … the `VimInfoModal.open()` call completes without error but no `.vim-motions-info-modal` appears in the document. Root cause likely related to the ex-commands test's `handleEx` wrapper or test state~~ (Fixed) — **the diagnosis above was wrong, and the crash-guard is why.** `VimInfoModal.open()` was never called: `:changes` was registered only in `setupVimSubsystems()`, and `reloadFeatures()` tears every registration down and rebuilds only those registered through `registerExCommands()`. Ex teardown did not remove the command — it re-defined it as `noopEx` — so `:changes` stayed _recognized_ (the fork never reported `Not an editor command`, nothing threw) while doing nothing at all. That is indistinguishable from an empty change list, which is what made the wrapper look like the culprit. Fixed by calling the fork's `undefineEx()` on teardown and registering `:changes` in the rebuilt path. The test now asserts the modal title is absent before the call and present after - ~~**`:e!` and `:update` ex command tests** remain crash-guards. Confirmed: `setupEditor()` uses `view.editor.setValue(text)` which sets in-memory content only … `:e!` reverts to the last saved disk state, not the `setupEditor` content~~ (Fixed) — **also misdiagnosed.** `:e!` was not reverting to anything: the fork's `parseInput_` ends a command name at the first non-word character, so `:edit!` parsed as command `edit` with argument `!` and the separately registered `defineEx('edit!', …)` handler was unreachable dead code. The command created and opened a junk note named `!.md` and emptied the buffer. `:update` was a genuinely awkward assertion, but for a different reason than recorded: Obsidian's idle autosave reaches the same on-disk state within ~2 s and masks a completely broken save, so the test now asserts the dispatched Obsidian command id instead of file content - **Golden comparison does not check register state or visual sub-mode type** — `compareStates()` compares `content`, `cursor`, `mode` (mode comparison added this release). The golden schema (`GoldenCase`) and recording infrastructure now capture `registers` (unnamed register text + linewise flag) and `visualMode` (`charwise`/`linewise`/`blockwise`) — 24 of 29 golden files include these new fields. However, **register comparison is disabled at runtime** because register state leaks between tests within the same Obsidian session (each `testWithNeovim` test shares the same editor instance, so registers from previous tests persist). Neovim golden recording starts each test fresh, producing a clean register state. Enabling register comparison requires either per-test register reset in Obsidian or comparing only tests that explicitly opt in via a flag. The golden data with registers is preserved for future use ### ~~`s` (substitute) test failure~~ (Fixed) **Status**: Fixed. Not a code regression — test vault `data.json` had `flashJumpEnabled: true`, which mapped `s` to flash jump mode instead of the built-in substitute (`cl`). Flash jump tests explicitly enable this setting in their own `before()` hooks and don't depend on the `data.json` value. Fixed by setting `flashJumpEnabled: false` in `data.json` and adding a defensive disable in `normal-editing.e2e.ts`'s `before()` hook. ## Neovim golden test coverage gaps The plugin verifies Vim behavior against headless Neovim via golden comparison tests (`test/neovim/`). The following areas of the fork's test suite are **not** covered by golden comparison because they cannot be meaningfully verified in a headless Neovim session: | 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**: `` + `I`/`A` + text + `` only applied the inserted text to the last selected line instead of all lines in the block. The replication step (which Neovim performs at `` exit from block-insert mode) did not complete before the RPC returned. - **Visual mode-switch + operator**: `jl` then `v` or `V` followed by `d` produced incorrect deletion scope — the mode switch didn't fully resolve before the operator executed. This caused the `visual-block` and `upstream-gaps` golden suites to contain incorrect expected values that matched `nvim_feedkeys` behavior rather than interactive Neovim behavior. The 4 failing `upstream-gaps` tests and all 15 `visual-block` tests had wrong expectations. Fixed by using `:execute "normal ..."` (via `nvim.command()`) for key sequences containing ``. This executes synchronously within Neovim's command loop, ensuring all side effects complete. Key sequences without `` (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](https://github.com/saberzero1/motions/issues/83)) Limitations: - **Desktop only**: Mobile devices do not support `child_process` and the feature is a no-op. The settings group is hidden on mobile. - **Command-line and search mode**: IM switching auto-wires to `CmdlineLeave` (switches to normal IM when exiting `:`, `/`, or `?` prompts). `CmdlineEnter` does not trigger an IM switch (users may need CJK input for search queries). The global ex command modal (`:` in non-editor views) does not fire `CmdlineEnter`/`CmdlineLeave`. - **System-wide switching**: IM switching is a system-wide OS operation. Switching IM in one Obsidian window affects all windows and applications. - **Flatpak/Snap**: Sandboxed Obsidian installations (Flatpak, Snap) may not have access to IM switching binaries outside the sandbox. Use the AppImage or native package instead. - **Binary must be pre-installed**: The plugin calls an external binary (`macism`, `fcitx5-remote`, `im-select.exe`, etc.) — it does not bundle one. The binary must be installed separately and the full path provided in settings. ### Deferred enhancements The following IM switching improvements are planned but not yet implemented: - ~~**Platform presets**~~: Implemented. A settings dropdown auto-fills binary path, arguments, and default IM for macism (macOS), im-select (Windows), fcitx5-remote (Linux), and ibus (Linux). Values are editable after selection. - ~~**Session persistence**~~: Implemented. The per-view IM cache is persisted to plugin settings via `saveData()` (30-second interval + save on unload). The saved IM is restored on plugin load. - ~~**`:IMToggle`/`:IMStatus` ex commands**~~: Implemented. `:IMToggle` enables/disables IM switching. `:IMStatus` shows the current IM identifier via a Notice. - **Content-type aware switching**: IM switching based on cursor context (e.g., auto-switch to English inside math blocks or code blocks) independently of vim mode. Users can implement this today by combining `vim.obsidian.im` with cursor position checks in Lua autocmds. - **`CmdlineEnter`/`CmdlineLeave` for global ex command modal**: The global `:` modal in non-editor views (Obsidian `SuggestModal`) does not fire cmdline autocmd events. Only the codemirror-vim editor dialog fires them. - **`CmdlineChanged` event**: An autocmd event that fires on each keystroke in the command-line prompt. Not needed for IM switching but useful for advanced Lua scripting. - **`cmdline` text in event data**: Including the actual command text in `CmdlineLeave`'s event data. Currently only `cmdtype` (`:`, `/`, `?`) is provided. - **Composition listeners on search dialog input**: The composition guard currently covers only the editor DOM element. CJK composition in the `/` search input is not tracked. This is acceptable because `CmdlineLeave` fires when the dialog closes (abandoning any active composition), but a more complete solution would track composition in the search input too. - ~~**`loadInitLua()` parameter refactor**~~: Implemented. The function now takes `(app, vim, options?)` with a `LoadInitLuaOptions` interface. ## Intentionally not supported These features are excluded by design and will not be implemented: | Feature | Reason | | ------------------------------- | ------------------------------------------------------------------------------------------- | | `jscommand` / `jsfile` in vimrc | Security risk — arbitrary JavaScript execution | | `cmcommand` in vimrc | Broken in CodeMirror 6, never fixed upstream | | ~~Input method switching~~ | **Built-in** since v0.51.0 — see **Settings → Vim Motions → Input method** | | ~~Yank highlighting~~ | **Built-in** since v0.47.0 — see **Settings → Vim Motions → Vim features → Yank highlight** | | Reading view navigation | Use the [vim-keynav](https://github.com/kometenstaub/obsidian-vim-keynav) plugin | | Vim toggle command | Use the [vim-toggle](https://github.com/conneroisu/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()` and `plugin.getState()` on the plugin instance — not part of the official `TasksApiV1` (which only exposes modal and toggle methods). The `obsidian-tasks-plugin:cache-update` workspace event is also undocumented. - **Dataview**: Uses `DataviewAPI.pages()` — a well-established public API exposed via `window.DataviewAPI`. The most stable of the three. All external API calls are wrapped in try/catch. If a target plugin changes its API shape, the integration silently degrades to empty results with a console warning. ### Deferred features The following are intentionally not implemented in v1: | 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()` and `d()` nodes have a 50ms debounce on recomputation. Very rapid typing may show stale computed values for a brief moment. - Lua function execution is time-guarded at 100ms per recomputation cycle. Functions exceeding this limit are skipped for that cycle. - Dynamic snippets triggered via the completion menu expand with their static body only — the dynamic context is not activated. Use Tab expansion for full dynamic behavior. - Nested `d()` nodes (dynamic nodes inside dynamic nodes) are not supported. - User snippet directory scanning requires desktop — vault-relative paths work on mobile, but absolute paths and `~` expansion are desktop-only. ### Snippet variable limitations - **`$CLIPBOARD` on mobile** — `navigator.clipboard.readText()` may be unavailable or permission-denied on mobile platforms and non-secure contexts. `$CLIPBOARD` resolves to `''` silently in these cases. On desktop, the clipboard is cached on `window focus` and `visibilitychange` events and read synchronously at expansion time. Intra-app vim `y`/`d` operations do not trigger the cache refresh — `$CLIPBOARD` reflects the system clipboard at the last focus/visibility event. - **`$TM_SELECTED_TEXT` / `$VISUAL` in tab-expand mode** — tab expansion requires an empty selection (`tab-expand.ts` returns `false` when selection is non-empty). `$TM_SELECTED_TEXT` and `$VISUAL` always resolve to `''` in tab-expand mode. Use the `:snippet` command in visual mode to expand snippets that wrap the selection. Visual line mode (`V`) and charwise visual mode (`v`) are both supported. Visual block mode (``) captures the bounding range text. - **Tabstop navigation after visual `:snippet`** — after expanding a snippet from visual mode via `:snippet`, vim is in normal mode (not insert mode). Tabstop navigation via Tab requires entering insert mode first. This is a pre-existing behavior of the ex-command pipeline — `exitVisualMode()` returns to normal mode before the handler runs. Snippets expanded from insert mode (tab-expand or completion) are unaffected. - **Picker snippet expansion does not capture visual selection** — the picker-based snippet expansion (`picker-source.ts`) reads `view.state.selection.main` which is collapsed after visual mode exit. The `:snippet` command uses vim marks to recover the visual selection; the picker does not have access to the `cm` adapter. This is a latent issue — the picker is typically invoked from normal mode. - **`snip.env` for Lua `f()`/`d()` callbacks** — deferred. The current `f(args, parent)` / `d(args, parent, old_state)` callback signatures do not carry environment variables. LuaSnip exposes `snip.env.TM_SELECTED_TEXT`, `snip.env.LS_SELECT_RAW`, etc. Adding this requires changes to `dynamic-bridge.ts` and the Lua function invocation protocol. - **`$LINE_COMMENT` / `$BLOCK_COMMENT_START` / `$BLOCK_COMMENT_END`** — deferred. These require cursor-context-aware language detection for code blocks. The simple case (`%%` always for Markdown) is trivial but not useful inside code blocks where `//`, `/* */`, `#`, etc. would be expected. ### Tabstop placement limitations A tabstop number may be repeated, and every occurrence is linked, as the LSP snippet specification requires — `$1 *a$2* *b$2* $0` puts a cursor in both `$2` positions and typing updates both. This is VS Code's and CodeMirror's presentation of that rule; Neovim's `vim.snippet` shows one cursor and mirrors the rest, so the resulting text agrees but the cursor count does not. Neovim additionally rejects a snippet whose repeated `${2:…}` placeholders carry _different_ default text, where each default here is inserted as written. - **A tabstop inside a table does not work in Live Preview.** Obsidian renders a Markdown table as an interactive widget that owns the region, so a tabstop landing between the pipes is inside a replaced range. The jump itself is placed correctly, but the next keystroke goes to the widget rather than the tabstop — with `|${2:---}|${2:---}|` a typed character was measured landing on an entirely different line while the selection stayed on the placeholders. This is not reachable from a transaction filter the way the emphasis case was: nothing can put a CodeMirror cursor inside a widget-replaced range. This remains true of the default **native** mode, where Obsidian's per-cell editor owns the keys. There are now two workarounds. **Source mode**, where both forms behave exactly as Neovim would: `|abc|def$2|` takes the typed character into the cell, and `|${2:---}|${2:---}|` becomes `|z|z|`. And **`set tablewidget=owned`**, where the plugin owns the surface: expansion, `Tab`, `Shift+Tab` and `Escape` all work in a cell, and the active tabstop is selected there. Expansion in `owned` did not work either until it was fixed — measured, `wla` left the literal prefix `| wlaaa | 11 |` and opened no session, because the nested cell editor carries no snippet extension and the parent's keymaps are unreachable while it holds focus. Two conversions Obsidian's own table editor performs are still missing in `owned`, so a body containing a literal `|` opens an extra column and one containing a newline does not survive. `set tablewidget=raw` is **not** a workaround — see the table-widget section below. ([#198](https://github.com/saberzero1/motions/issues/198)) ### ~~Ex command snippet expansion~~ (Fixed) ~~`:snippet ` and `:snippets` (picker) commands are registered but expansion via the test harness's `Vim.handleEx()` bridge does not produce visible results.~~ Fixed. The commands were silently broken after any `reloadFeatures()` cycle (vimrc load, Lua config load, settings change). `registerSnippetCommands()` was only called in `onload()`, but `reloadFeatures()` calls `unregisterAll()` which replaced all snippet ex commands with no-ops and never re-registered them. The Picker-based snippet insertion was unaffected (separate `pickerRegistry`). Fixed by adding `registerSnippetCommands()` to `reloadFeatures()`. ([#95](https://github.com/saberzero1/motions/issues/95)) ## Vim keybindings in text areas **Status**: Experimental (disabled by default). ([#69](https://github.com/saberzero1/motions/issues/69)) When enabled (**Settings → Vim Motions → Vim features → Vim keybindings in text areas**), focused `