github saberzero1/motions 1.5.0

6 hours ago

Added

  • :files, :buffers and :recent show an icon for each file's type (#171).
    • Icons: 22 file types, from Markdown, canvas, base and Excalidraw (including .excalidraw.md) to PDF, images, audio, video, tables, code, archives, Office documents and EPUB, with a generic file icon for the rest.
    • How they are drawn: they live in styles.css as URL-encoded SVG data URIs and are drawn as masks painted in Obsidian's theme colours, so they follow light and dark themes. That fixes the contrast the icon sets' own fixed colours fail on a light background. main.js does not grow; styles.css grows by about 21 KB, including the licence notices.
    • Overrides: --vim-motions-icon-<type> and --vim-motions-icon-<type>-color replace any icon or colour.
    • Style Settings: the Picker section gains toggles to hide the icons or paint them in one colour.
    • Sources: the icons are from Material Icon Theme (MIT). Canvas and base use Lucide's layout-dashboard and layout-list (ISC), the icons Obsidian shows for them. The licence notices are in styles.css.
  • EasyMotion motions have vim-easymotion's <Plug> names, including the bidirectional ones that had no key (#205).
    • Names: <Plug>(easymotion-bd-w), -bd-e, -bd-W, -bd-E, -bd-jk, -bd-t and -bd-f, and one for every motion with a default key (-f, -w, -j and so on) plus -repeat. The bidirectional motions existed but were unreachable, and the docs' advice to remap "using the internal command names" led nowhere.
    • Use: a key mapped to a <Plug> name keeps the motion's behaviour after an operator, in visual mode, with a count and under dot-repeat. A Lua function mapping would run as an action instead.
    • Non-recursive mappings: nnoremap and a plain vim.keymap.set are non-recursive, and the engine did not remap a <Plug> name from a non-recursive right-hand side, as Vim and Neovim do. It ran the name as literal keys, and their i inserted text into the note.
      • Measured on Neovim 0.12.5: nnoremap Q <Plug>(down)l with nmap <Plug>(down) j runs j and then a plain l.
      • Where it is fixed: in the codemirror-vim fork, which this needs at 6.4.7.
  • The picker's fonts, sizes and layout can be changed with CSS variables or the Style Settings plugin (#171).
    • Variables: --vim-motions-picker-font, --vim-motions-picker-font-size, --vim-motions-picker-preview-font-size, --vim-motions-picker-width, --vim-motions-picker-height and --vim-motions-picker-results-width.
    • Truncation: the body class vim-motions-picker-truncate ends long names with an ellipsis instead of wrapping them.
    • Style Settings: all of these appear in a new Picker section. Each var() falls back to the picker's own default, so leaving one unset changes nothing.
    • Padding: the first result now clears the floating "Results" title it overlapped by 4px.
  • <C-y> in a picker yanks the selected item (#171).
    • What it yanks: from :commands, which :obcommand with no argument opens, the command id that :obcommand, vimrc and Lua take. From :files, :buffers and :recent, the file's path. Elsewhere, the item's label.
    • How: it goes through the register controller as y does, so the text lands in the unnamed register and register 0, and in the system clipboard when clipboard is unnamed or unnamedplus. Then the picker closes, so p pastes it.
    • Keys: configurable as yank in vim.obsidian.pick_keymap.
    • Upgrades: settings saved before this kept their whole pickerKeymap through the shallow settings merge, without yank, and vim setup would have thrown on the first load. Saved picker keymaps are now merged with the defaults on load.
  • One Escape closes Obsidian's link suggestions and leaves insert mode, as one <Esc> closes Neovim's completion menu (#196).
    • Before: the suggestion menu handled Escape on window in the capture phase and stopped it, so the engine stayed in insert mode, and a second Escape was needed. A user imap <Esc> could not help, since the engine never saw the key. Ctrl+[ left insert mode but left the menu open.
    • Now: the plugin notices Obsidian took the key (defaultPrevented on the same event) and sends <Esc> to the editor afterwards, if it is still in insert mode. That <Esc> goes through user <Esc> mappings, as Neovim's would.
    • Neovim 0.12.5, measured: A <C-n> <Esc> ends in normal mode with the selected word kept.
    • Option: on by default. Settings → Vim Motions → General → Escape always leaves insert mode, set noescapeleavesinsert or vim.opt.escapeleavesinsert = false turns it off.
    • Not covered: pop-out windows, where listener order is not guaranteed, and the Neovim backend. Both are recorded in KNOWN_LIMITATIONS.md.
  • K previews footnotes, a second K moves into the preview, and q closes it (#209, #208).
    • Footnotes: K on a footnote reference ([^1]) used to show character info. It now sends the same hover-link Obsidian's own footnote hover sends, linktext: "#[^1]", so the footnote opens in a preview.
    • KK: a second K, with the cursor where the first left it, moves into the preview, as Neovim's hover does ("KK" opens the hover window and focuses it, :help vim.lsp.buf.hover()). After a motion, K starts over, since Neovim closes its hover window when the cursor moves. It uses the preview's showEditor(), which is what a click into the preview does.
    • q: in a preview, q in normal mode with nothing pending closes it, as q dismisses Neovim's hover window. In insert mode it types a q, and while a macro is recording it ends the recording.
  • Experimental tableWidgetMode: reveal: Obsidian renders idle tables, and the table holding the cursor becomes the note's own editable lines. The parent editor's vim then edits the table directly — one document, one vim state, one undo history, native insert mode — with no nested editor and nothing to bridge, and tables the cursor is not in keep Obsidian's formatted widget with its buttons, drag, sort and menus. The earlier probes concluded a table's source could not be revealed in Live Preview; they had tested selection placement, precedence overrides and CSS, but not Obsidian's own exemption: its table builder skips any table touched by the obsidian-search-match-highlight mark in the Editor.addHighlights() field, which is how in-file search shows a match inside a table, unchanged from 1.5.8 through 1.13.7. The plugin captures Obsidian's highlight effect types once, from a synthetic state, and a transaction extender places that mark over each row of every table the selection touches in the same transaction that moves the selection there, so the table is never replaced around the caret. Obsidian keeps calling its hidden widget for that table, which would open a cell editor and cause the cursor jump of #121; the extender retires that instance first so Obsidian's builder drops it. The :table* commands and their <leader>t bindings work on a revealed table through text fallbacks, since Obsidian's versions drive the widget it does not have. The active table is shown as an aligned source grid — monospace, inline markup as source, delimiters dimmed, the active cell highlighted — and a click on an idle table places the cursor in the clicked cell. It works unchanged under the Neovim backend, where the cursor follows Neovim into the table and Neovim's extmarks render inside it — both of which a nested-editor design could not do. It also works with Obsidian's own Vim key bindings: the surface is registered outside the plugin's vim layer, which stands down when they are on. Desktop only for now.
    • Plugin: src/vim/table/reveal/ (new: table-extent.ts, highlight-channel.ts, reveal-field.ts, widget-retire.ts, grid-decorations.ts, widget-input.ts, surface.ts), src/vim/table/gates.ts, src/main.ts, src/settings.ts (both implementations), src/vim/options.ts, src/vimrc/loader.ts, styles.css
  • In a revealed table, insert-mode Tab and Shift-Tab move between cells, Enter moves to the same column of the next row, and Shift-Enter inserts <br>. Tab in the last cell and Enter on the last row add a row. Both wrap across rows and skip the separator. Shift-Enter writes <br> because that is what Obsidian's cell editor writes for a line break typed in a cell (measured at 1.13.7: 1<br>x); a plain newline would split the row. At the end of a row, with nothing after the caret, both keys start a new line as in plain text, so a table can still be typed row by row. The first version moved down there too, and table-reveal.e2e.ts's #121 scenario caught it: once a separator is typed the table is revealed, and the Enter ending it dropped the next row into a new row's first cell, | | | 1 | 2 | |. mkdnflow.nvim makes its Enter navigation opt-in for the same reason. This is how Obsidian's own table editor moves in Live Preview, measured at 1.13.7, and what mkdnflow.nvim, table-nvim and org-mode do, so reveal now follows Obsidian's convention for its tables rather than Vim's insert-a-tab. The cursor lands at the start of the next cell's content, where Obsidian selects that content, because a selection is not an insert-mode state in Vim. Shift-Tab in the first cell does nothing, where Obsidian inserts a row above the header. A snippet that can expand or walk its tabstops keeps Tab, as does an insert-mode mapping, and normal mode and lines outside tables are unchanged.
    • Plugin: src/vim/table/reveal/cell-keys.ts (new), src/vim/table/reveal/cells.ts (new), src/vim/table/reveal/surface.ts
  • tableautorealign realigns a revealed table when you leave insert mode after editing it, the way Obsidian's table editor does when it commits a cell. It is off by default, keeping the documented promise that the plugin never reformats a table unasked. Realigning on a natural boundary rather than per keystroke follows vim-table-mode, table-nvim and org-mode.
    • Undo: the realign is labelled input.type.compose, which CodeMirror's history always joins to the previous event, so one u undoes the edit and the realign together, like vim-table-mode's undojoin. The vim adapter uses the same label to merge its own changes.
    • Cursor: stays on the character it was on, in the same cell, when an earlier column changes width.
    • What is left alone: a table with any row that does not both start and end with a pipe, so a row still being typed is never rebuilt. Rebuilding one would drop the text after the last pipe. <C-o> does not end the session.
    • What counts: a quoted table keeps its > markers, and a cw with nothing typed counts as an edit.
    • Engines: works with Obsidian's own Vim key bindings as well as the bundled engine. Neither assist applies under the Neovim backend, which owns every key.
    • Plugin: src/vim/table/reveal/realign-on-leave.ts (new), src/vim/vim-api.ts (getAnyVimAdapter, which also reads Obsidian's built-in adapter), src/settings.ts (both implementations), src/vim/options.ts, src/vimrc/loader.ts, src/main.ts
  • A revealed table shows which column the cursor is in. The status bar reads Col 2/3 · Value, with the column, the column count and the header, and warns, for example row has 4 cells, when the cursor's row has a different number of cells from the header, so a stray or missing pipe shows while you type. rainbow_csv and csv.vim show the same in their statuslines. Every cell of the cursor's column is highlighted, more faintly than the cursor's own cell.
    • Plugin: src/vim/table/reveal/column-status.ts (new), src/vim/table/reveal/grid-decorations.ts, src/vim/table/reveal/surface.ts, src/main.ts, styles.css
  • Typing a pipe in reveal mode: \| inside a wikilink, and || for a separator.
    • Wikilinks: a | typed inside an unclosed [[ on a revealed row is written as \|, so [[page|alias]] or an embed size stays in its cell. Obsidian's cell editor escapes every pipe; a revealed table escapes only these, because elsewhere a typed pipe is how a column is made.
    • Separator: || typed as the whole of the line below a header with no separator yet becomes the separator, sized to the header's cells. This is vim-table-mode's shortcut, and this plugin's own before it moved to Obsidian's table editor. Unlike that version, it leaves a || row of an existing table alone.
    • Plugin: src/vim/table/reveal/typing.ts (new), src/vim/table/reveal/surface.ts
  • <Leader>tal, <Leader>tac and <Leader>tar set the current column's alignment, and the alignment commands now work in a revealed table. :tablealignleft, :tablealigncenter and :tablealignright already ran Obsidian's commands, which drive its table widget and so did nothing to a revealed table. They now fall back to rewriting the separator cell and realigning, like the other :table* commands.
    • Plugin: src/motions/tables.ts, src/vim/table/structural.ts (setColumnAlignment)
  • Images: h and l select an embedded image, and :image resizes it, as under Obsidian 1.13's own Vim mode (#218). The plugin's engine replaces the Vim adapter that provided both, so they were missing whenever it ran.
    • Selecting: in idle normal mode, l at an image's start selects it, with Obsidian's accent outline and buttons, and l again leaves it at its end; h does the same from the end. A count or pending operator moves as before, so 2l and dl are unchanged. The check runs on the physical key, ahead of mappings, as Obsidian's does. This departs from Neovim on purpose, because an embed is Obsidian syntax drawn as one object.
    • :image grow|shrink|reset|edit: works as Obsidian's command does, with the count taken from the line address (:3image grow). Two additions: the count can also be given as an argument (:image grow 3), and with no image selected the command selects the one under the cursor. Without an image to act on, it says so, where Obsidian's does nothing.
    • No g+/g-/g=/g<CR>: Obsidian binds these, but g+ and g- are Neovim's undo-history keys, already used by the undo tree.
    • Hint mode: an embed's top-right buttons, such as an image's Zoom in and Edit this block, get labels while they are visible.
    • Neovim backend: :image is bridged. The resize reaches Neovim's buffer as one change, so u undoes it, the way every edit Obsidian makes now does (see Fixed).
    • Plugin: src/vim/image-embed.ts, src/vim/image-command.ts, src/vim/image-select-keys.ts (new), src/ui/hint-mode.ts, src/rpc/obsidian-feature-bridge.ts, src/main.ts

Changed

  • Picker file names keep non-Markdown extensions, and outlines indent headings by level instead of showing # marks (#171).
    • File names: :files, :buffers and :recent list a note without .md and any other file with its extension, as Obsidian's quick switcher does. Measured: the switcher lists Pick171, Pick171.png and Pick171.pdf, where :files listed all three as Pick171. :recent already did this.
    • Outlines: gO, :outline and :headings show each heading indented two characters per level, without # marks, as Neovim's gO does (measured on 0.12.5: Top, Second, Third level, from level 1 even when a note starts at level 2). :outline now lists headings in document order instead of by frecency: with no history, the label tie-break had listed them deepest first.
  • The table widget dropdown shows short labels: Native, Reveal (experimental), Raw (deprecated). Each option used to be a full sentence; the Reveal one ran past 250 characters, and Obsidian sizes a dropdown to fit its label. What each mode does is now the setting's description. Both settings implementations read the same TABLE_WIDGET_OPTIONS and TABLE_WIDGET_DESCRIPTION, and the deprecated option is listed last.
    • Plugin: src/settings.ts (both implementations)
  • tableWidgetMode: raw is deprecated, with a one-time notice, ahead of removal in a later release. It does not do what its name implies — measured, it hides Obsidian's widget with CSS while Obsidian still block-replaces the table's range, so the table renders as nothing, which is why Source mode is the recommended replacement. The value keeps working: it is a documented vimrc and Lua option, and a deprecation that breaks configs is not a deprecation. The legacy shapes that used to resolve to it now retarget to native instead — suppressTableWidget: true and tablewidget=always, in both the stored-settings migration and the separate vimrc/Lua option mapping — while an explicitly chosen raw is deliberately left alone. docs/configuration/vimrc.md and docs/configuration/lua-config.md also omitted a value and now list every one. The table-widget migration moved out of main.ts into src/settings-migration.ts, where its siblings already live, so it is unit-testable.
    • Plugin: src/settings-migration.ts (new migrateTableWidgetMode), src/main.ts, src/settings.ts (both implementations), src/vim/options.ts
  • skipInTableCells() is deleted — it never skipped anything. It returned the extension unchanged plus a CSS class referenced nowhere in styles.css, so all 14 of its call sites in src/main.ts read as keeping a subsystem out of Obsidian's per-cell table editors and none of them did. Measured, of those 14: one does per-view work a gate can reach — the scrolloff enforcer's EditorView.updateListener, which ran 8 times inside a cell across two keystrokes, calling coordsAtPos and getBoundingClientRect each time — and it is now gated on classifySurface. Four are facet-based gutter({...}) extensions with no per-view condition, suppressed only by the existing .cm-table-widget .cm-gutters rule, which now carries a comment saying so. Four are StateFields or fold-service facets that receive an EditorState and never see a view. One is a properties MutationObserver that is inert in a cell by construction, so gating it would be unobservable and therefore unverifiable. The scrolloff gate needs no connected-view latch, unlike the treesitter bridge's: an updateListener has no construction phase, and its selectionSet guard already implies the view is in the document.
    • Plugin: src/vim/scrolloff.ts, src/main.ts (14 wrappers removed), src/util/cell-editor-guard.ts (deleted), styles.css (comment)
  • native remains the default table mode, deliberately. reveal is new and experimental: a table changes shape when the cursor enters or leaves it, long rows wrap rather than scroll, mobile is unmeasured, and the cross-platform suite has not been run against a flipped default. It stays opt-in; native is unchanged and is not deprecated.
  • A click in an idle table finds the character under the pointer with the standard caretPositionFromPoint. It used WebKit's caretRangeFromPoint, which is deprecated and was the only lint warning left. Chromium gained the standard call in 128, and the 1.5.8 installer the minimum-version tests run is Chromium 120, so the old call stays as a fallback where the new one is missing.
    • Plugin: src/vim/table/reveal/widget-input.ts

Removed

  • The experimental tableWidgetMode: owned surface, added and removed within this release, never shipped. It rendered a table's source in a nested editor whose keys, text, selection and focus were bridged back to the parent; reveal makes the active table the parent's own lines, which needs none of that bridging and does what owned could not — Obsidian's formatted rendering for idle tables, and Neovim's rendering inside the table. A stored owned from a development build migrates to reveal, and set tablewidget=owned or vim.opt.tablewidget = "owned" retargets to it with a console warning.
    • Plugin: deleted src/vim/table/{nested-view,key-router,sync-up,selection-mirror,surface-field,surface-widget,click-to-place,cell-decorations,native-cell-suppressor,renderable-ranges}.ts; src/settings-migration.ts, src/vim/options.ts, src/vimrc/loader.ts, src/settings.ts (both implementations), src/main.ts, styles.css

Fixed

  • The hotkey-conflict check now covers Ctrl+E, Ctrl+T and Ctrl+G. Obsidian binds them by default to Toggle reading view, New tab and Open graph view, which took <C-e> scrolling, insert-mode <C-t> indenting and <C-g>. Pressing Ctrl+E to scroll left the editor for reading view. The startup notice and Check hotkey conflicts list them with the same unbinding instructions as the others. Ctrl+N, Ctrl+P and Ctrl+6 also collide, but are left to Obsidian: j, k and :e # do the same. The test vault unbinds the two new defaults it had left bound, as it does the others.
  • Holding the keyboard for a modal input no longer lets Obsidian's hotkeys run underneath it.
    • Hint mode: holding Ctrl over a hint label could run an Obsidian command instead of opening the label in a new pane. Labels start with a home-row letter, and Obsidian binds Ctrl+D (Delete paragraph), Ctrl+F, Ctrl+G, Ctrl+H, Ctrl+K and Ctrl+L by default. Measured with Obsidian's defaults: Ctrl on a d label deleted a line of the note, and Ctrl on an f label opened search.
    • Flash, EasyMotion and vim.fn.getcharstr(): Ctrl+D while flash jump input or EasyMotion labels were up, or while getcharstr() waited, deleted a paragraph, and the input never saw the key.
    • The fix: Obsidian's Keymap handles a key before any of these listeners. While a capture holds the keyboard, Obsidian's hotkeys are now paused, the way a modal pauses them, and restored on every exit. captureKeys does this for hint mode, flash and EasyMotion, and the getcharstr broker does it while it listens.
    • Still differs from Neovim: getcharstr() returns a Ctrl chord as its letter (d), not the control character ("\4").
  • :outline and :headings highlighted the wrong characters for a match. Highlights index into the label, but the label carried an indent and # marks the matcher never saw. Typing thi highlighted three characters of indent instead of Thi. The depth is now rendered as spacing beside the label, and the label is the bare heading.
  • The animated cursor painted the old letter after an edit, and did not follow an edit that moved it (#181, follow-up reports). There were two faults:
    • Stale glyph: the block cursor repaints the character under it, cached by document position and row. x and r change that character without moving either, so the deleted or replaced letter was drawn again. This is the "text artifacts while typing and editing" report.
    • No wake: the cursor woke only for a selection change, a scroll or a focus change. Enter on an empty nested bullet outdents it and maps the caret through the change without setting the selection. The cursor stayed a tab stop to the right until the next move. A document change now drops the cached glyph and wakes the cursor.
  • Hint mode could not edit properties, press Add property, or type into the backlinks search (#207). There were three causes:
    • No label: a property value is a contenteditable div and Add property a plain div, so neither matched a hint selector. Editable elements other than the note's own editor are now targets, and so is the add button.
    • Focus taken back: a property name and the backlinks search are inputs and did get labels, but 150 ms after the click hint mode handed focus back to the note.
    • The fix for that: it now leaves focus alone when it is on a field you can type into, so the field hinted keeps the cursor. So does the name field Add property opens. After a checkbox or a button, focus still returns to the note.
  • A picker result opened in a new tab or split landed on line 1 instead of its line (#201). This affected :livegrep (built-in search or ripgrep), :grep, :headings, :outline and :tasks. The split and tab paths started the open without waiting for it, then set the cursor 100 ms later on whichever view was active by then. An open slower than that lost the race, which is why it depended on the machine and never showed in the test vault. Opening in the current tab already waited for the open. openInSplit now takes the line and applies it once the open has finished, in the leaf it opened. With Always focus new tabs off, the cursor used to go to the note the picker was opened from.
  • :obcommand from a normal-mode mapping acted on an earlier visual selection (#200). A mapping such as vim.keymap.set("n", "<C-b>", function() vim.cmd("obcommand editor:toggle-bold") end) bolded whatever had last been selected, however long ago. Running Open configuration in default editor the same way re-selected that text. The fork leaves visual mode before it runs an ex command, so :obcommand restores the selection that exit discarded (#161, #192). It restored '</'> whenever they existed, though, and they outlive the selection by any number of keystrokes. It now restores them in two cases only:
    • the command carries '<,'>;
    • visual mode ended in the same task, which src/vim/visual-exit.ts records from the mode-change event, so an exmap that dispatches again still counts. Without either, the command runs at the cursor. A typed single-line range no longer falls back to the marks either.
  • Lua function mappings stopped working after any settings change that ran reloadFeatures(), until Obsidian restarted. The registration's unregisterAll() redefines every action it held as a no-op. applyLuaMaps then remapped the keys but redefined only actions whose names it had not seen. Found while testing #211, where enabling text-area vim killed the mappings under test. The recorded names are now cleared wherever the registration is rebuilt.
    • Plugin: src/main.ts
  • vim.cmd() from a Lua mapping fired in a vim-enabled text area ran in the note behind it, or was skipped with "no active editor" (#211). Both the ex path in the runtime handler and the normal special case in api.ts resolved the active note. Lua callbacks now record the adapter of the editor their key fired in, saved and restored like the vim.v context, and vim.cmd() runs there. Timers and autocmds keep using the active note.
    • Plugin: src/lua/api.ts (getCallbackAdapter), src/main.ts
  • The hotkey-conflict check now covers the jump list: Ctrl+O (Quick switcher) and Ctrl+I (Toggle italics) (#206). Obsidian binds both by default, and its Keymap stops the event on window before the editor sees it. Measured: with the italic binding active, Ctrl+I inserted italic markers instead of jumping forward. The startup notice and Check hotkey conflicts now list them with the same unbinding instructions as the others. The notice says "Vim keys" rather than only "workspace navigation keys", since the jump list is not workspace navigation. Normal-mode <Tab> is <C-i> and is unaffected. The test vault already unbound Ctrl+O, which is why the specs passed. This addresses the measurable cause; whether it accounts for everything in #206 is for the reporter to confirm.
    • Plugin: src/workspace/hotkey-conflicts.ts, src/main.ts
  • Escape in a nested page preview closed the preview beneath it and left the focused one open (#210). The idle-Escape guard hid the active view's hoverPopover, which with nested previews is the first one, and Obsidian's hide() keeps a child that still holds the focus. The guard now finds the popover holding the focused editor by walking childHovers, and hides that one. Whether the preview beneath then stays is Obsidian's own hover logic.
    • Plugin: src/vim/escape-guard.ts
  • Normal-mode Tab indented the line; it is now <C-i>, as in Vim and Neovim, and walks forward through the jump list, cross-note entries included. The bundled engine lets functional keys it has no mapping for reach Obsidian, and normal-mode <Tab> had none, so Obsidian's editor keymap indented the line. In a table that turned | aa | 11 | into \t| aa | 11 |. In Obsidian's own table cell editor (native mode), Tab still moves to the next cell, as #136 relies on: the mapping marks itself fromTab, and there the key is run through the cell editor's own keymap with CodeMirror's runScopeHandlers, rather than reimplementing Obsidian's cell move. Under Obsidian's own Vim key bindings the plugin registers no mappings, so Tab still reaches Obsidian there. Related to #206.
    • Plugin: src/main.ts, src/vim/normal-tab.ts (new), src/types/vim-api.d.ts
  • :tablerealign placed the content of right- and centre-aligned columns at the left edge, where Obsidian places it by the separator's colons. The text fallbacks in reveal share the same formatter. Switching a table between native and reveal therefore rewrote those columns back and forth, because Obsidian's editor realigns its way on every commit. realignTableLines now pads exactly as Obsidian's TableEditor.getTableString() does: right-aligned against the right edge, centred with the odd space after the content. Widths stay in UTF-16 code units, as Obsidian measures them, so CJK and emoji rows still do not line up in a monospace grid. Matching Obsidian byte for byte was chosen over display width.
    • Plugin: src/vim/table-utils.ts
  • A legacy tablewidget value from a vimrc or Lua was written into the setting raw. cursor, embedded, off and always were stored as-is and corrected to native only afterwards, by the bundled engine's option callback — which is not registered under Obsidian's own Vim key bindings, where the invalid value stayed. The vimrc and Lua paths now retarget legacy values before the setting is written.
    • Plugin: src/vimrc/loader.ts
  • The table cursor guard and the :table* text fallbacks now ask about the cursor's own table. Both asked whether any table widget was on screen, which is a different question once some tables are revealed and others are not: the guard would have hidden the caret in a revealed table, and the fallbacks refused to run there, whenever another table in the note kept its widget.
    • Plugin: src/vim/table-cell-cursor-guard.ts, src/motions/tables.ts
  • Escape could not cancel a pending Neovim command while a table cell editor was open, stalling the RPC pipeline with no way out. TableNavController.installCellEscapeCapture registered a document-capture keydown handler calling stopImmediatePropagation(), which destroyed the event before the key-delegation listener on the editor could forward <Esc> to nvim_input. Any Neovim command awaiting its second character — after Z, d, g, ", a count or an operator — therefore could not be cancelled, and RPC API requests stalled indefinitely. Measured: the identical Z then Escape sequence recovers in 3 ms with no cell editor open and never recovers with one, across two Escapes. The capture now skips stopImmediatePropagation() while a backend other than the bundled fork owns keys, resolved through isExternalBackendActive(), which the connection lifecycle already maintains. Behaviour with RPC disconnected is unchanged, so the documented two-step cell-edit(insert) → cell-edit(normal) → table-nav Escape transition still holds for the bundled engine. The underlying stall is normal Neovim behaviour awaiting a command's completion and is not table-specific — it reproduces with no table involved — so it is not treated as a defect.
  • A table-nav entry scheduled across an edit could take the editor down. scheduleEntry() captured the table's range and its entryTimer !== null guard stopped any later update replacing the pending candidate, so an edit inside the ENTRY_DEBOUNCE_MS window left the captured to past the end of the document. Nothing caught it: the only validation was cursorInRange(from, to), which still passes because the cursor is numerically inside the stale span. The stale tableTo then reached tableNavStateField, where the next document-changing transaction maps it and throws Position 41 is out of range for changeset of length 37 from inside a StateField update, which takes the field and the editor with it. Reproduced by ca| on a 41-character table, whose change leaves 37 characters while an entry scheduled before it is still pending. tryEnter() now resolves the table from the current state and rejects a range extending past doc.length.
    • Plugin: src/vim/table-nav-controller.ts
  • Leaving a table without a keystroke left the cursor invisible and the keyboard dead, in the default table mode. Entering a Markdown table in tableWidgetMode: native activates table-nav, which suppresses the fork's vim cursor and empties its layer — and nav only ever exited from its own key handlers. So anything that moved the caret without a keystroke (a picker jump, :42, a mark, a harpoon slot, all of which dispatch a selection) left nav active with the caret outside the table: measured at viewSuppressed: true, overrideCount: 1, the cursor layer display: none with zero children, and a following j not moving the head at all. Leaving with real k presses was always correct — overrideCount: 0 and one visible cursor — which is why this survived: every keyboard path hides it. table-cell-cursor-guard could not compensate either, because its update() early-returns while nav is active. Nav's update() now schedules a deferred, fail-closed exit when a selection-only transaction leaves the caret outside every table. It must be deferred, because the teardown dispatches and CodeMirror rejects a reentrant dispatch from a ViewPlugin.update() — called directly it aborts partway, leaving the session holding the suppression it was trying to release. The frame re-validates rather than captures (session state, document identity, caret position, vim quiescence), since that window is exactly where an operator can start; the quiescence check is fail-closed because the existing isCellVimIdle treats a null state as idle, which is right for a cell editor still initialising and wrong here. The teardown is a separate routine rather than a placement variant of the keyboard exit: it dispatches exitTableNav before destroying Obsidian's cell editor, and it does not reposition the caret or take focus. Cursor failures first blamed on another surface were this defect leaking across tests through the fork's EditorView-keyed suppression WeakMap, which setPluginSettingAndReload does not reset. test/specs/table-cursor-surface-parity.e2e.ts is no longer skipped and now also asserts that the caret is not repositioned on the way out.
    • Plugin: src/vim/table-nav-controller.ts
  • The treesitter bridge allocated a WASM parser and retained a syntax tree for every table cell editor Obsidian opens. Obsidian's Live Preview table editor creates a separate CM6 EditorView per cell, so every extension registered through registerEditorExtension() is instantiated again for a one-line cell document — and the bridge is the most expensive of them. Measured against a real Obsidian 1.13.7: with the gate removed, a cell editor holding aa reports a published tree; with it in place it reports none, while the parent editor keeps its own. Two earlier attempts tried to keep the extension out of the view's configuration with StateEffect.appendConfig dispatched from a ViewPlugin and installed it nowhere — main editors included — passing their cell-side assertions for exactly that reason; a parent control is now mandatory in any test of this. The branch also cannot be taken where it looks like it belongs. A cell editor is constructed detached (measured at isConnected: false, no .cm-table-widget ancestor, document "aa"), so a surface classified in the plugin's create function reads document for every cell and gates nothing; the decision and the parse are deferred to the first update on a connected view, which is the mechanism table-cell-cursor-guard.ts already relies on. The latch fails in the safe direction — a surface it cannot classify keeps its bridge rather than silently losing one. The four gutters are not covered: they are built with CodeMirror's facet-based gutter() API, which has no per-view condition, and are hidden with CSS in cells rather than excluded.
    • Plugin: src/util/surface-gate.ts (new classifySurface), src/treesitter/bridge.ts (GatedTreeSitterBridge), src/main.ts (getTreesitterSurfaceReport())
  • A realign is refused while the cursor is in insert mode. Realignment replaces the whole table, and three facts make that dangerous rather than untidy mid-insert: the fork's adapter exposes changed regions with no meaningful origin, insert recording accepts origin === undefined, and a table-spanning replacement is therefore eligible to enter lastInsertModeChanges — after which . replays a formatter edit. Both realign paths now share one guard.
    • Plugin: src/vim/table-operations.ts, src/motions/tables.ts
  • The cursorline could tint a line inside Obsidian's table cell editor, and the claim that it could not was wrong. An earlier entry in this release stated the defect "does not exist", on a local measurement of zero .vim-motions-cursorline elements inside .cm-table-widget in all three cursorlineopt modes. CI measured one, on Linux, macOS and Windows alike, so whether the decoration applies is environment-dependent and the measurement should never have been generalised. The line and both modes are a StateField, which receives an EditorState and never sees a view, so classifySurface cannot withhold them the way it withholds the scrolloff listener; the fix neutralises the background instead. display: none remains wrong for the reason already recorded — buildCursorlineDecorations puts the class on the real line via Decoration.line, so hiding the element hides the cell's text.
    • Plugin: styles.css
  • The vim fork was hiding the caret of an editor mounted inside another, across the editor boundary. BlockCursorPlugin.update() sets display: none on every .cm-cursorLayer:not(.cm-vimCursorLayer) it finds in its own scrollDOM — right for its own view, wrong for a separate EditorView mounted inside that scrollDOM and draws its caret with plain drawSelection(). Measured on a table-cell editor mounted that way: its layer held one caret, was focused, and was inline display: none, which is also why every probe of that caret reported 0x0 — a hidden element has no box. The fork now skips layers whose closest('.cm-editor') is not its own view.dom, in both update() and destroy(); the guard is generic and mentions no table. Verified against a local fork build (display: block, a 1x19 caret inside the cell's box) with a negative control that restores display: none and 0x0, and the fork's own suite green across four partitions at 1622 passing. Shipped in @saberzero1/codemirror-vim@6.4.4, which this release consumes; the regression spec runs unskipped.
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (ownsLayer guard in update() and destroy(), documented in the fork's DIFFERENCES.md)
    • Plugin: package.json (alias bumped to ^6.4.4)
  • Entering a table no longer leaves the editor dead after a Live Preview → Source switch. Three separately-owned pieces of global state survived the transition — the fork's per-view cursor suppression override, the fork's global key interception, and focus itself — leaving an editor that drew no cursor and accepted no keys until it was clicked. It affected native, the default, so it was shipped behaviour; it reproduced on every attempt. Each piece had its own cause. The cursor guard's update() early-returned unless selectionSet || focusChanged, and a mode switch is neither for the parent — its head does not move and it does not take focus — so the release branch never ran; it now re-evaluates on every update while suppression is held. The table-nav session stayed alive because cursorInAnyTable is keyed on the table's document range, which is still a table in Source mode; it now abandons when the widget it drives leaves the DOM, which releases both the intercept and the suppression abandonNav already knew how to clear. (#167)
    • Plugin: src/vim/table-cell-cursor-guard.ts (re-evaluate while suppressed), src/vim/table-nav-controller.ts (widget-gone abandon reason, orphaned-focus reclaim in abandonNav)
  • Neovim backend: a cross-note jump could land at the top of the note instead of the remembered position. <C-o>, <C-i>, Harpoon and :HarpoonSelect open the note, set CodeMirror's cursor, then move Neovim to match. Any request in between copied Neovim's cursor back over the restored one, because each request first syncs from Neovim. At that point Neovim's cursor was still at the top of the reseeded buffer. The host's cursor now wins until Neovim is level. A jump that moves nothing, such as <C-o> with nothing older, gives the cursor back after 300 ms, so keys typed meanwhile still show. This was the intermittent macOS failure of the two jump-list scenarios in rpc-obsidian-bridge.e2e.ts.
    • Plugin: src/rpc/document-sync.ts (holdHostCursor), src/rpc/key-delegation.ts (no cursor copy while held, re-sync on release), src/rpc/obsidian-feature-bridge.ts (held by the mapping and ex-command paths)
  • Neovim backend: <C-w>v<C-w>h stayed in the new pane. Obsidian activates a split's new pane before it loads the note into it. A bridged action arriving in that window found no editor and was dropped without notice. A mapping that splits and then moves therefore lost the move every time: 3 runs out of 3. Bridged actions now wait for a pane that is still loading, up to 1.5 s, and start in the order Neovim sent them. Both parts are needed, and either one alone still fails. This was also the intermittent macOS failure of focuses the expected pane in all four directions.
    • Plugin: src/rpc/obsidian-feature-bridge.ts (resolveActiveEditor, the action turn in onNotification)
  • Neovim backend: edits Obsidian makes to the note now reach Neovim. Sync ran only from Neovim to the editor. A ticked checkbox, an :ob command that changes text, another plugin's edit, or a change to the file on disk stayed out of Neovim's buffer. Neovim's next change to that line was then applied against the old text: A! after an API edit turned NEW alpha beta into NEW alpha !beta. Every edit that does not come from Neovim's line events now goes to Neovim's buffer as one change, so u undoes it.
    • Neovim's echo: dropped, as an activation's reseed is. Applying it is not harmless: replacing 15 lines with 2 came back as lines 0–15 and failed against the 2-line editor.
    • An edit during an activation: sent once the activation finishes, because its reseed would overwrite the edit.
    • An edit for a note the buffer no longer holds: dropped. That note's next activation reseeds from the editor.
    • Plugin: src/rpc/host-edits.ts (new), src/rpc/document-sync.ts, src/vim/image-command.ts (its own forwarding removed), src/main.ts
  • <C-w>v<C-w>h stayed in the new pane under the bundled engine too. The Neovim backend's fix above covered only keys that go through Neovim. Sent together, from keys or from a mapping, the move ran while the split was still loading its note, and the finished load focused the new pane again: 4 runs out of 4.
    • Fix: window and tab commands now wait for a pane that is still loading, up to 1.5 s, and run in order. With nothing loading they run at once, as before.
    • Which pane is loading: the check reads the active pane, as Obsidian's pane commands do. getMostRecentLeaf() still named the original pane right after the split, so a check on it passed and the move ran during the load. The Neovim bridge now shares the same check.
    • Plugin: src/workspace/leaf-settle.ts (new), src/workspace/navigation.ts, src/rpc/obsidian-feature-bridge.ts

Tests

  • hotkey-collisions.e2e.ts keeps the plugin's keys and Obsidian's default hotkeys accounted for. It reads Obsidian's default hotkeys, so the test vault's unbinding hides nothing, and asks the engine whether it would take each one, in normal, visual and insert mode. Measured on 1.13.7: 12 of 43 collide with Mod as Ctrl, none with Mod as Cmd. Collisions with the global mappings used outside the editor count too.
    • What it enforces: every collision is classified in test/fixtures/hotkey-collisions.ts, either in the conflict check or as a deliberate "Obsidian wins" with a reason. A row that no longer collides fails it too. hotkey-collision-table.test.ts pins the conflict-check rows to WORKSPACE_NAV_CONFLICTS.
    • Built-in controls: binding <C-k>, and separately <M-k>, must surface as a collision with Insert link.
    • Negative controls:
      • dropping the Mod+P row fails it, naming the unclassified collision;
      • adding a Mod+K row fails it as stale;
      • a detector that never sees a claimed key fails the table check and both controls, leaving only the four global-mapping collisions;
      • marking Mod+E "Obsidian wins" fails the unit pin.
    • Tests: test/specs/hotkey-collisions.e2e.ts (new), test/fixtures/hotkey-collisions.ts (new), test/unit/hotkey-collision-table.test.ts (new), test/unit/hotkey-conflicts.test.ts
  • hint-mode-ctrl-hotkeys.e2e.ts and capture-obsidian-hotkeys.e2e.ts press Ctrl+ while a capture holds the keyboard, with Obsidian's default hotkeys restored for the session. The test vault unbinds most of the colliding hotkeys, which is why the existing tests never saw it.
    • Hint mode: a d label and an f label. Before the fix, the note lost a line (1149 to 1121 characters) and search opened.
    • Other captures: Ctrl+D during flash jump input, during EasyMotion labels, and while getcharstr() waits. Before the fix, each lost a line.
    • Controls: in both specs, Ctrl+D is Obsidian's again once the capture ends.
    • Negative controls:
      • captureKeys not holding the keymap fails the hint, flash and EasyMotion scenarios;
      • the getcharstr broker not holding it fails only the getcharstr scenario;
      • either owner never releasing it fails the control after its capture.
    • Labels: each label scenario puts its letter first in the hint characters. With the default asdfghjkl, labels reach f only past 78 targets, and CI's smaller window showed fewer, so the f scenario found no label on every platform. With five targets, the default labels reproduce that failure, and the letter-first labels pass.
    • Unit tests: key-capture.test.ts pins one push and exactly one pop across every exit, and fails if the release guard is removed or the capture never holds the keymap.
    • Tests: test/specs/hint-mode-ctrl-hotkeys.e2e.ts (new), test/specs/capture-obsidian-hotkeys.e2e.ts (new), test/unit/util/key-capture.test.ts
  • picker-icons.e2e.ts reads the icons :files draws for six real files (#171).
    • Scenarios:
      • a distinct mask per type, in its own colour: the PDF in --color-red, the unknown extension in the muted fallback;
      • a colour override variable;
      • both Style Settings toggles.
    • Negative controls:
      • rendering no icon element fails all three;
      • removing the icon CSS fails all three;
      • removing the .excalidraw.md rule fails the first, with md.
    • Unit tests: picker-icons.test.ts checks that every type's data URI decodes to an SVG and has an overridable colour, that common vault extensions map to their icons, and pickerFileType. Corrupting the PDF's data URI fails it.
    • Tests: test/specs/picker-icons.e2e.ts (new), test/unit/picker-icons.test.ts (new), test/unit/picker-style-settings.test.ts
  • easymotion-plug.e2e.ts maps F-keys to <Plug>(easymotion-...) names and presses them (#205).
    • Scenarios: bd-w labels words on both sides and jumps back; it still works after d; it is reached from a plain vim.keymap.set (non-recursive) and from nmap.
    • Fork 6.4.6: the non-recursive scenario fails, and the other three pass.
    • Fork with the fix: all four pass.
    • Negative controls: leaving the <Plug> names unregistered fails all four. The fork's vim_noremap_plug test fails with its change reverted and the bundle rebuilt.
    • Tests: test/specs/easymotion-plug.e2e.ts (new); fork: test/vim_test.js (vim_noremap_plug)
  • picker-style.e2e.ts reads the picker's computed styles (#171).
    • Scenarios: unset, it matches the previous values and the first result clears the title; the font, size, width and height variables and the truncation class take effect; the preview font size and the results column beside the preview take effect.
    • Against the previous CSS: all three fail. The first result overlapped the title by 4px, and the variables had no effect.
    • A slip it caught: a first edit put the width variable on the prompt modals' rule, which had the same two lines, instead of the picker's.
    • Unit tests: picker-style-settings.test.ts pins the Style Settings ids to the variables and class the CSS reads, both ways. A typo in one id fails all three of its tests.
    • Tests: test/specs/picker-style.e2e.ts (new), test/unit/picker-style-settings.test.ts (new)
  • picker-yank.e2e.ts presses <C-y> in real pickers (#171).
    • Scenarios: a command from :obcommand yanks its id into " and 0 and closes the picker; a file from :files yanks its path; a keymap saved without yank still gets it after a reload.
    • Before the change: <C-y> did nothing and left the picker open.
    • Negative controls:
      • removing the key handler fails all three;
      • removing the commands source's yankText fails the command scenario with the label Toggle bold;
      • removing the keymap merge fails all three, since the test vault's own saved settings predate yank.
    • Tests: test/specs/picker-yank.e2e.ts (new)
  • picker-names-outline.e2e.ts reads the real pickers and the gO modal (#171).
    • Scenarios: :files and :buffers with a note, a PNG and a PDF of the same name; gO and :outline on a note with headings at three levels; the :outline match highlight.
    • Before the change: all five failed. The file pickers showed Pick171 three times, the outlines showed # marks, and the highlight covered three spaces.
    • Negative controls:
      • putting :outline back on frecency fails only its order;
      • removing the indent rule from styles.css fails only its indentation.
    • Updated spec: files-buffers-non-md.e2e.ts (#169) expected the stripped TestCanvas, and now expects TestCanvas.canvas. Its two assertions fail when the old stripping is restored.
    • Tests: test/specs/picker-names-outline.e2e.ts (new), test/specs/files-buffers-non-md.e2e.ts
  • escape-leaves-insert.e2e.ts presses Escape with real link suggestions open (#196).
    • Scenarios: one Escape closes them and leaves insert mode, keeping the text; with the option off, the first Escape only closes them; a user inoremap <Esc> X that stays in insert mode is left alone; a plain Escape still leaves insert mode (control).
    • Before the change: the first scenario stayed in insert mode.
    • Negative controls:
      • not installing the listener fails the first scenario;
      • ignoring the option fails the second;
      • dropping the defaultPrevented check fails the mapping scenario with abXX, because the injected <Esc> ran the user's mapping a second time.
    • Tests: test/specs/escape-leaves-insert.e2e.ts (new)
  • keyword-preview.e2e.ts presses K, KK and q against real page previews (#209, #208).
    • Scenarios: K on a footnote; K on a link (control); KK focusing the preview and q closing it; K after a motion not focusing; q typing in insert mode; q ending a recording; q in the note still recording a macro (control).
    • Before the change: K on [^1] showed character info.
    • Negative controls:
      • removing the footnote branch fails the footnote scenario;
      • deleting the KK call fails the focus scenario and the q scenarios that build on it;
      • dropping the unmoved-cursor rule fails only the motion scenario;
      • an inert q listener fails the closing scenario;
      • no idle check fails the insert-mode scenario;
      • no recording check fails the recording scenario.
    • A sabotage that proved nothing: a first KK control written as focusKeywordPreview(view) && false still called the function, and passed for that reason. With the call actually removed, the scenario fails.
    • Unit tests: footnote-ref.test.ts pins footnote detection, including that [^id]: at the start of a line is the definition. Removing that rule fails it.
    • Tests: test/specs/keyword-preview.e2e.ts (new), test/unit/workspace/footnote-ref.test.ts (new)
  • A Neovim download that times out no longer kills a CI shard.
    • Before: scripts/install-neovim.ps1 called Invoke-WebRequest once, and a connection to GitHub's release download that never answered failed a whole Windows shard before any test ran ("A connection attempt failed because the connected party did not properly respond"). Its own -MaximumRetryCount retries HTTP error codes only, not that.
    • Windows now: it retries the request up to four times, backing off 5, 10 and 15 seconds.
    • Linux and macOS: scripts/install-neovim.sh now passes curl --retry 3 --retry-all-errors, as fetch-test-plugins.sh does.
    • Checked locally:
      • against a closed port, Get-Archive, taken from the script itself, warned, retried and then threw;
      • the real nvim-win64.zip downloaded whole;
      • install-neovim.sh installed Neovim and passed its API-level check.
    • Scripts: scripts/install-neovim.ps1, scripts/install-neovim.sh
  • animated-cursor-edits.e2e.ts reads the pixels painted on the animated-cursor canvas (#181). Smoothing and the smear trail are off, so the paint is the resolved caret rect.
    • Scenarios: x and r under a block cursor, each compared with a fresh paint of the same position; a change that moves the caret without setting the selection; Enter outdenting an empty nested bullet, checked against CodeMirror's caret.
    • Before the fix: the glyph scenarios differed by 32 and 33 pixels, the old letter, and the cursor sat 37.5 and 35.7 px from the caret.
    • Negative controls:
      • keeping the glyph cache across edits fails only the two glyph scenarios;
      • not waking on an edit fails only the two position scenarios.
    • Why the glyph scenarios paint the position first: the glyph cache only goes stale when the row top is bit-identical. A first version, without that paint, passed with the invalidation removed.
    • The comparison is a glyph mask, not bytes: two measurements can place the block a fraction of a pixel apart, which changes edge anti-aliasing (alpha 32 against 17 at a corner).
    • Tests: test/specs/animated-cursor-edits.e2e.ts (new)
  • hint-mode-properties.e2e.ts drives real hint labels at the fields around a note (#207).
    • Scenarios: a property value, a property name, Add property, and the backlinks search with its filter shown. A task checkbox is the control.
    • Before the fix:
      • the value and the add button got no label;
      • the name and the search lost the cursor to the note;
      • the control passed.
    • Negative controls:
      • removing the focus guard fails all four;
      • removing the contenteditable selector fails the value;
      • removing the add-button selector fails Add property.
    • Unverified exclusion: excluding checkboxes from "fields you can type into" survives its sabotage. A toggled checkbox does not keep focus in Live Preview, which re-renders it, nor in reading view (measured), so no scenario reaches it.
    • Tests: test/specs/hint-mode-properties.e2e.ts (new)
  • picker-split-cursor.e2e.ts opens line-bearing picker results with the real picker keys (#201). Every scenario slows WorkspaceLeaf.openFile by 300 ms, the condition the reporter's machine met and this vault did not.
    • Scenarios: :livegrep in a new tab and a vertical split; :grep and :headings in a new tab; :livegrep in a background tab with Always focus new tabs off; :livegrep in the current tab as the control.
    • Before the fix: the four tab and split scenarios landed on line 0, and the control passed.
    • Negative controls:
      • dropping the line in openInSplit fails the same four;
      • placing the cursor on the active view instead of the opened leaf fails the background-tab scenario: the new tab stayed on line 0 and the note behind it moved.
    • Tests: test/specs/picker-split-cursor.e2e.ts (new)
  • obcommand-stale-selection.e2e.ts presses the issue's own Lua mappings (#200). The capture command records the selection Obsidian hands to an editor command.
    • Normal mode: right after Escape, and after moving to another line. Both captured beta against the old handler.
    • Leaving insert mode: leaving insert mode and running the command in the same task must not revive the selection.
    • Still restored: a visual-mode mapping, and an explicit '<,'> range.
    • Negative controls:
      • treating every selection as fresh fails both normal-mode scenarios with beta;
      • never marking one fails the visual-mode mapping and all three #161 scenarios in obcommand-visual-mode.e2e.ts, exmap included;
      • never expiring the mark fails both normal-mode scenarios;
      • marking on an insert exit fails the insert-mode scenario.
    • Tests: test/specs/obcommand-stale-selection.e2e.ts (new)
  • lua-keymap-reload.e2e.ts and textarea-vim-lua-cmd.e2e.ts.
    • lua-keymap-reload: presses a Lua mapping across two reloads. Without the fix, the register it writes stayed empty.
    • The normal! x scenario: keeps a note open behind the text area. Against the old path, the text area stayed area text and the note lost its first character.
    • The obcommand scenario: has no note active. Against the old handler, Obsidian's command was never dispatched.
    • Keys: the mappings are on F7 and F8. Alt chords never fired on macOS CI, because the engine reads a lone Option there as a composed character, and WebDriver sends Option+y as y. obcommand-stale-selection.e2e.ts uses F7 and F8 for the same reason.
    • Tests: test/specs/lua-keymap-reload.e2e.ts (new), test/specs/textarea-vim-lua-cmd.e2e.ts (new)
  • jump-list.e2e.ts presses a real Ctrl+I. The test vault's hotkeys.json now unbinds editor:toggle-italics, as it already did switcher:open. Before that the scenario failed: the cursor stayed on line 0 instead of reaching line 4. The conflict list's unit test pins both new entries. The scenario is skipped on macOS, where Obsidian's modifier is Cmd and the conflict does not exist. WebDriver's synthesized Ctrl+I there also did not reach the jump list, and what it delivers instead was not measured.
    • Tests: test/specs/jump-list.e2e.ts, test/unit/hotkey-conflicts.test.ts, test-vault/.obsidian/hotkeys.json
  • hover-popover-escape.e2e.ts opens page previews by Ctrl-hovering real links, so Obsidian's popover nesting is real, and clicks into the preview to make it an editor.
    • Nested preview: fails against the old guard, with the focused preview, Deep note text, still open after Escape.
    • Single preview: with hide() removed, it fails with one preview still open.
    • Tests: test/specs/hover-popover-escape.e2e.ts (new)
  • lua-plugin-mini-comment no longer checks the gc mapping at a fixed moment, or under only one of its two names.
    • What failed: "gc mapping is registered after setup" failed once on a macOS runner, 200 ms after the config load. Every later scenario that presses gc passed in the same run.
    • Why the old check was wrong: setup() maps gc twice over: as lua-action-eager-N while the config loads, and as lua-action-N when applyLuaMaps runs on the next reloadFeatures or leaf change. The check accepted only the first.
    • Ruled out: a hypothesis that a startup Lua load raced the test's own load was measured and dropped. With the startup path delayed 3 s, gc stayed mapped, because Lua is not enabled at startup there.
    • New check: the scenario waits up to 5 s for gc mapped to either kind of Lua action.
    • Negative control: with both registration paths disabled, it fails with setup() never mapped gc to a Lua action.
    • Tests: test/specs/lua-plugin-mini-comment.e2e.ts
  • Enter, Shift-Enter, the row text objects and the rendered table settings.
    • Enter and Shift-Enter: five table-reveal-editing.e2e.ts scenarios, with six sabotages, each failing its own:
      • Enter unbound: the header row was split;
      • no separator skip: the cursor stopped on the separator;
      • a new row's cursor in column 0: column 2 instead of 9;
      • Shift-Enter unbound: the row was split with no <br>;
      • Enter swallowed outside tables: no new line;
      • no end-of-row rule: the row typed after Enter landed inside a new row's first cell, in the new scenario and in #121's.
    • ir/ar: previously documented as raw-only. They now have a reveal scenario.
    • table-settings-labels.e2e.ts: opens the settings tab and reads the rendered dropdown. It skips the hidden select.is-measuring Obsidian adds to size it. Against the previous settings.ts it failed, showing the old sentences; with the realign item renamed, its toggle test failed.
    • Negative-control records: these and the earlier reveal-editing sabotages are now in test/specs/table-reveal-editing-negative-controls.md. The aligned conformance cases are in test/specs/table-realign-conformance-negative-controls.md.
    • Tests: test/specs/table-reveal-editing.e2e.ts, test/specs/table-settings-labels.e2e.ts (new), test/specs/table-reveal-editing-negative-controls.md (new), test/specs/table-realign-conformance-negative-controls.md
  • Column status, the typing assists, the alignment commands, normal-mode Tab, and the existing cell motions in a revealed table, each with negative controls. Twelve sabotages each failed their own scenario:
    • Typing and alignment:
      • escape removed: the alias split the cell, [[page|alias]];
      • escape applied everywhere: an ordinary delimiter became aa\|z;
      • separator off: the line stayed ||;
      • header check reduced to "has cells": a || row of an existing table became a separator;
      • fallbacks removed: the alignment commands left the table unchanged.
    • Column awareness: status removed (nothing shown); mismatch ignored (no warning); column mark removed (no highlighted cells).
    • Tab: mapping removed, so the jump stayed at line 0 instead of line 4; the cell handler's insert-mode guard removed, so under Obsidian's vim normal-mode Tab reached column 9.
    • Existing motions: i| taking the opening pipe yanked | aa ; [r without the separator skip stopped on the separator.
    • Moved test: the guard's normal-mode test moved to the built-in-vim spec. Under the bundled engine, normal-mode Tab is now consumed by its mapping before any keymap runs, so the old test could no longer fail.
    • Caught regression: table-nav-disabled.e2e.ts caught the first version of the <Tab> mapping consuming Tab in Obsidian's cell editor, where #136 needs it to move cells. The cell column stayed at 0. It passes again with the cell-editor hand-back, and that failure is the hand-back's negative control.
    • Unit tests: setColumnAlignment has three cases, each failing under its own sabotage.
    • Realign guard: a probe confirmed that canRealignTable, which reads only the bundled engine's adapter, is unreachable under Obsidian's own Vim key bindings. There :tablerealign reports "Not an editor command", so the guard is left as it is and its comment records why.
    • Tests: test/specs/table-reveal-editing.e2e.ts, test/specs/table-reveal-builtin-vim.e2e.ts, test/specs/jump-list.e2e.ts, test/unit/table/structural.test.ts
  • Insert-mode Tab, tableautorealign, and the realign formatter's alignment rules, each with negative controls.
    • table-reveal-editing.e2e.ts (12 scenarios) drives real keys, because Tab is a keymap binding that Vim.handleKey never reaches. Ten sabotages each failed their scenario:
      • insert-mode guard removed: normal-mode Tab reached column 9;
      • separator not skipped: Shift-Tab stopped at {3, 8};
      • no new row: the cursor stayed at {5, 9};
      • key swallowed outside a table: the line outside the table was unchanged;
      • plain input.type label: u undid only the realign, leaving | aaxxxx | 11 |;
      • row guard removed: extra was dropped;
      • no cw seed: no realign after the deletion;
      • prefix dropped: the > markers were lost;
      • cursor not mapped: column 13 instead of 11;
      • setting ignored: the table was realigned with the setting off.
    • A first draft of the S4 sabotage was itself vacuous. It broke a path the reveal check already guards, which is why the recorded S4 makes that check swallow the key.
    • The built-in-vim spec gains Tab and realign scenarios. Both failed until the adapter was resolved through editorView.cm: Tab inserted a character at column 3, and the table stayed as typed.
    • The Neovim reveal spec asserts that insert-mode Tab reaches Neovim. Its softtabstop folds the preceding space into the tab.
    • The realign conformance spec compares aligned tables against Obsidian's live rebuildTable(). Every earlier specimen sat in a |---|---| table, which is how the alignment mismatch went unnoticed. It failed first with | apple | 3 | ok | against Obsidian's | apple | 3 | ok |.
    • Two unit expectations that pinned the left-aligned output now follow Obsidian. Ignoring alignment again fails three cases.
    • Tests: test/specs/table-reveal-editing.e2e.ts (new), test/specs/table-reveal-builtin-vim.e2e.ts, test/specs/rpc-table-reveal.e2e.ts, test/specs/table-realign-conformance.e2e.ts, test/unit/table/realign-width.test.ts, test/unit/table/structural.test.ts
  • Two vimrc specs no longer read the configuration before the plugin has finished loading it. Both failed intermittently on macOS, with the same source that had passed there one run earlier.
    • gmap-vimrc: its setup reloaded Obsidian, then wrote the vimrc and reset vimrcLoaded/vimrcLoading to force a second load. Measured at the reset, a startup load had already read the missing vimrc 22 ms earlier and was still running. When it finished last, it marked the vimrc loaded with 0 commands. The spec now writes the vimrc first and reopens the same vault in place, so every load reads the real file.
    • cursorlineopt-config: it ran reload-configuration, which does not await its reload, then paused a fixed 900 ms. A slow runner read the previous value, and its two "rejects" cases passed without the new vimrc ever being read. The command now records its reload, and the spec awaits it through the new configReloadSettledForTest() hook.
    • Red first: latency was injected after the vimrc read, with a missing file finishing 2 s before the real one. The old specs failed exactly as CI did: expected > 0, got 0, and expected "graph", got "markdown" for gmap-vimrc; expected "both", got "number" for cursorlineopt-config. The new specs pass under the same latency.
    • Negative controls: a settle hook that resolves at once brought back "number". A normalizer that accepts invalid values now fails the "rejects" case (not "line,screenline"), which the old fixed pause had let pass.
    • Tests: test/specs/gmap-vimrc.e2e.ts, test/specs/cursorlineopt-config.e2e.ts
    • Plugin: src/main.ts (reload-configuration keeps its promise; test hook)
  • RPC spec cleanup no longer fails when Neovim exits on its own. Fourteen specs cleaned up with if (pidIsAlive(pid)) process.kill(pid, 'SIGKILL'). That runs right after disabling the backend, so Neovim is already shutting down and can exit between the check and the kill, which then throws kill ESRCH. That is how rpc-editor-options failed a macOS shard after all three of its tests had passed. The shared killIfAlive kills without a liveness check and treats ESRCH (process already gone) as success; any other error still fails. All 14 specs now use it, and so does rpc-latency, whose cleanup used to swallow every error. Twelve local pidIsAlive copies that only served the old check are gone.
    • Unit test, written red first: a reaped process. Under the old bare kill it threw Error: kill ESRCH, the same error as CI.
    • Negative controls: a no-op helper left the child 'running', against the expected { code: null, signal: 'SIGKILL' }. Swallowing every error let a NaN pid pass without the expected ERR_INVALID_ARG_TYPE.
    • Tests: test/specs/rpc-process.ts (new), test/unit/rpc/kill-if-alive.test.ts (new), 15 test/specs/rpc-*.e2e.ts cleanup hooks
  • The Windows end-to-end shards run Obsidian's latest installer; Linux and macOS keep earliest. All 36 Windows shards failed with not connected to DevTools. On the windows-2025 20261021 runner image, the 1.5.8 installer's GPU process (Electron 28) crashes with STATUS_ACCESS_VIOLATION about every 2.3 s from launch, and Chromium ends the browser on the ninth crash. Any spec running longer than about 20 s therefore died, along with the cold-cache setup. Branch probes isolated it to the installer:
    • Ruled out: the Neovim install, the fetched test plugins and the cold Obsidian cache.
    • Did not stop the crashes: --disable-gpu.
    • Stopped the crashes: --no-sandbox, and the latest installer (Electron 43), which recorded none against 13 for 1.5.8.
    • Tests: wdio.conf.mts
  • The owned surface's specs that guard shared behaviour now run against reveal; the rest went with it. The structural-command, realign-guard, cursor-parity and state-leak specs test properties a revealed table also depends on — text fallbacks without a widget, the insert-mode realign guard, the cursor guard, and surviving Source mode, modals, tab switches, blur and a pane split — so they were converted rather than deleted. The conversions found two defects in reveal before release, both fixed: undo and dot-repeat died after :tablerealign, because the vim adapter labels its edits as IME compositions and the extender skipped them; and Source mode showed the reveal grid. A new arm pins that switching from reveal back to native leaves table-nav working, which owned did not.
    • Tests: test/specs/table-structural-reveal.e2e.ts (renamed), test/specs/table-realign-guard.e2e.ts, test/specs/table-cursor-surface-parity.e2e.ts, test/specs/table-state-leak.e2e.ts
  • Twelve more reveal scenarios: line-level features inside a revealed table, and Obsidian's own vim. IME composition, flash, EasyMotion, hint mode, snippets and the animated cursor each act on a revealed row and are asserted against it, and the built-in-vim spec reboots Obsidian with the setting on so the gate is evaluated as a user of it meets it — a runtime toggle let a restored blocker pass. The IME scenarios found a corruption before release: a composition at a row's end made the extender rewrap the composing text, deleting the row's leading pipe and leaving the preedit beside the commit. The highlights are now left alone during a composition and resync afterwards.
    • Tests: test/specs/table-reveal-integrations.e2e.ts, test/specs/table-reveal-builtin-vim.e2e.ts, test/unit/table/reveal-field.test.ts
    • Plugin: src/vim/table/reveal/reveal-field.ts, src/vim/table/gates.ts, src/main.ts, src/settings.ts (both implementations)
  • Eighteen end-to-end scenarios for tableWidgetMode: reveal, four more under the Neovim backend, and 29 unit cases, with the negative controls in test/specs/table-reveal-negative-controls.md. One control found a defect rather than confirming a test: with the reveal disabled the renderer hung, because converting Obsidian's cell editor into a caret placement looped whenever the placement could not reveal its table. Another removed a scenario — d3j from above the table — that passed with the reveal disabled. The unit tests drive the extender through a real EditorState against a stand-in for Obsidian's highlight field and a duck-typed widget, so the same-transaction behaviour is tested without Obsidian.
    • Tests: test/specs/table-reveal.e2e.ts, test/specs/rpc-table-reveal.e2e.ts, test/unit/table/reveal-extent.test.ts, test/unit/table/reveal-field.test.ts, test/unit/known-set-options.test.ts
  • Six scenarios for the raw deprecation plus nine unit cases for the migration, with six negative controls in test/specs/table-raw-deprecation-negative-controls.md. Two of those controls did not fire at first and each exposed a different hole: the once-per-session scenario used document edits as its stimulus, but the notice path runs on a settings reload, so the flag was never exercised; and nothing covered the vimrc/Lua option path at all, so the always mapping change in src/vim/options.ts was unverified until two handleEx scenarios were added. Controls 3 and 4 are deliberately asymmetric — one breaks the legacy cases and leaves the explicit raw passing, the other does the reverse — because retargeting a legacy value and migrating a user's explicit choice are separate decisions.
  • Two specs for the cell-editor guard, plus six negative controls in test/specs/table-cell-guard-negative-controls.md. The scrolloff spec asserts a count rather than geometry, because a one-line cell editor has scrollHeight ≈ clientHeight and the enforcer's own >= 1 threshold means it never writes scrollTop there — "it did not scroll" holds whether or not the listener ran. Its parent half is mandatory: a cell count of zero is identical whether the gate withheld the listener or it never installed anywhere, which is precisely how two earlier surface-gate attempts passed while doing nothing. The cursorline spec pins an absence, so its control inverts the assertion, and each scenario separately proves the selector matches something by asserting the parent's highlight with the cursor outside a table.
  • One control is recorded as not applicable rather than given a passing substitute: the "classification moved into a ViewPlugin create function" sabotage has nothing to act on when the gate lives in an updateListener, which has no construction phase.
  • A real-Obsidian gate measurement for the treesitter surface branch (test/specs/table-surface-gate.e2e.ts), asserting in one run that a live table cell editor has no syntax tree, that it is classified table-cell, and that the parent editor still has its own — the last being the only reading that distinguishes "withheld from cells" from "never installed", which is how both earlier attempts passed while doing nothing. A precondition asserts a cell editor actually exists, without which the two cell assertions pass trivially. The unit side (test/unit/fold/bridge-metadata.test.ts, test/unit/util/surface-gate.test.ts) covers the detached-then-connected sequence the product actually performs, against real WASM. Seven negative controls are recorded in test/specs/table-surface-gate-negative-controls.md, including the one that caught a flaw in a test fake rather than in the product: an earlier closest answered while detached, which let the cell test survive a sabotage that should have broken it.
  • A 12-scenario characterisation suite for RPC behaviour around table cell editors (test/specs/rpc-table-cell-diagnosis.e2e.ts), crossing key type against table context and probing a fast API (nvim_get_mode), a non-fast one (nvim_win_get_cursor) and renderer liveness. It exists because the defect was first reported with a mechanism that source refutes: enqueueInput does not chain keys on prepareKeyInput(), which is await this.frontmatterFold.sync() and returns immediately once the mode matches. The suite's decisive control presses the same prefix key with no table involved and reproduces the stall, which is what established that the stall and the unrecoverability are two different things. Acceptance is probed at a 35 s deadline, past the 30 s REQUEST_TIMEOUT_MS, so a request left pending cannot satisfy it; negative-control evidence is recorded in test/specs/rpc-table-cell-diagnosis-negative-controls.md.
  • The table formatter is now pinned against Obsidian's own, nine specimens compared byte-for-byte with a live TableEditor.rebuildTable() in native mode, plus eleven unit cases for fast feedback. realignTableLines had no unit coverage at all beforehand. The planned change this guards — a displayWidth() helper replacing String.length — was not made, because measuring the oracle showed Obsidian pads by UTF-16 length too (日本語 → column width 3, not 6; a ZWJ emoji → 8, not 2), so the helper would have broken the byte-for-byte match the same step required.
  • The first version of that spec was vacuous and the negative control is what caught it. It drove :tablerealign against the document, and in native mode Obsidian's widget reformats the table immediately afterwards — so the document converged on the oracle whatever our formatter produced, and a deliberately wrong width model reported 9 of 9 passing with the sabotage verified live in the source. The spec now calls the pure function directly through a new formatTableLines() accessor, after which the same sabotage fails exactly the four specimens where the two width models disagree and keeps the five where they agree. Two habits made it findable: verifying the sabotage was present before believing its result, and predicting the per-case isolation in advance so an all-green run was recognisable as wrong.
  • Nineteen unit cases for the table layout model, the pure text-to-geometry model the reveal grid and its click mapping share. Malformed shapes get defined answers rather than throws, since the model runs inside a decoration pass where an exception takes the editor down.
  • Five scenarios for the realign guard, with three negative controls in test/specs/table-realign-guard-negative-controls.md. Control 1 asserts the guard directly rather than through dot-repeat, as the step requires, and the isolation shows why: the dot-repeat scenario keeps passing under that sabotage because it requests its realign from normal mode — dot-repeat is the symptom the guard prevents and cannot stand in for the guard. The spec now runs against a revealed table, and converting it caught a focus defect: undo and dot-repeat died after a realign, from a different cause than the plan predicted, fixed above.
  • Control 3 did not fire, so another unverifiable rule was deleted, as skipInTableCells() was before it. The planned composition guard is unreachable, because every realign entry point is a normal-mode operation while composition happens in insert mode, which the insert-mode guard already refuses. It is also unverifiable: a scenario written for it failed on its own precondition, since view.composing stays false after a synthetic compositionstart — CodeMirror sets composing to 0 there and the getter tests > 0. That precondition assertion is what turned a would-be passing guard into a visible finding.
  • isolateHistory was not used and is not needed. @codemirror/commands is not a declared dependency, only a transitive one, and ships its own nested @codemirror/state that TypeScript rejects outright; the annotation is an AnnotationType instance, so a copy of it is not the one the host's history reads and could compile while silently doing nothing. Measured, the behaviour it was meant to buy already holds once focus is correct: one u undoes the realign and leaves the typed character, a second removes it.
  • Fourteen scenarios for the structural commands on a revealed table and twenty-two unit cases for the operations, with four negative controls in test/specs/table-structural-reveal-negative-controls.md. Controls 1 and 2 are deliberately crossed — removing the fallback fails the eight scenarios that expect a change and none of the absence scenarios, while removing the in-table guard fails exactly the outside-table one — so neither can stand in for the other.
  • Control 3 exposed a split in what each suite can see. Making column operations skip the separator row leaves all fourteen e2e scenarios passing, because realignTableLines rebuilds the separator from the data rows' column count — but it does not rebuild the alignment markers, which it reads back out of it. The e2e fixture is |------|------|, every column unaligned, so there is nothing to misplace; the unit fixture is |:---|---:|:--:| and fails exactly the two alignment cases. The e2e proves cell counts, the unit tests prove alignment, and neither substitutes for the other.
  • The cursorline spec failed in CI while passing locally, and the failure was both a test defect and a product one. It asserted an element count, a property measured as 0 locally and 1 in CI; it now asserts the painted background, which is what a user sees, and carries a scenario that injects a probe element to test the suppression rule directly so it is verifiable wherever the decoration happens not to apply.
  • A cursor guard parameterised over every table surface — native and reveal. It exists because the existing suite structurally cannot catch this regression class: counted across the four prior fixes' specs, there are four cursorOnTableLine === false assertions and one parentCursorVisible === false, and not one asserting that a cursor is drawn. A suite built only from absences is satisfied by a cursor that renders nowhere, and every one of those specs pins tableWidgetMode = 'native', so none could see another surface. The spec asserts a non-zero-extent cursor outside the table, and each surface's own expectation inside it: none under native, the parent's under reveal, where it is the only caret. Findings in test/specs/table-cursor-surface-parity-findings.md.
  • test/specs/table-state-leak.e2e.ts, a four-arm regression spec for the leak fixed above. ARM C enters no table and must stay healthy, so the spec cannot pass by measuring the mode switch instead of the table; ARM D runs native, where the defect was worst. It asserts painted cursors and that a real j still moves the head, not only the suppression flags — measured necessary rather than assumed: reverting the cursor-guard fix leaves viewOverrideCount at a healthy 0, because the view reconfigure fires the plugin's destroy(), which clears the override but never repopulates the cursor layer the guard emptied. A flags-only spec stays green through that.
  • Two tests in table-cursor-vanish.e2e.ts asserted the defect, and were inverted. expect(fork.keyInterceptActive).toBe(true) after an LP→Source switch, under a describe titled "Bug 2: Cursor visible after LP→source" — green for the whole life of the bug, and red the moment it was fixed. A third, forceTableCleanup via bridge clears all stuck state, was deleted: its helper performed the three mutations the test then asserted, so it could not fail for any implementation. Both survivors now also assert that a keystroke still drives the editor, because main.ts's keyInterceptSafetyHandler clears the intercept on the next keydown and a scenario that pressed a key first could not tell a correct release from a leak papered over by the backstop.
  • Ten vacuous assertions across the table suite, found by audit and each replaced against a measurement. The pattern was uniform: a reading the scenario already computed, discarded in favour of something that could not fail. rpc-table-cell-edit asserted longProbe.deadlineMs > 30000 — the 45000 it passed in twelve lines earlier — while recoveredEventually went to JSON unread; four pipeline-health readings were collected and only the pre-table control asserted. tables.e2e.ts asserted a direction for six cell motions and never a destination — ]| landing on the B cell is ch 6, but > 3 also accepted the delimiter at 4 — and ]r/[r, named "same column", never compared the two columns. table-nav-mode asserted a |-line count for o, O and dd; measured, o and O produced byte-identical documents from their respective cursors, so the pair could not distinguish the operations however the assertion was phrased, and O now runs from the second data row. "modifier combos should not be swallowed" pressed j. Four scenarios claiming a key works without the nav overlay checked the overlay before the key and not after, or never. And tables.e2e.ts's realign no-op sat behind three silent early returns with an absence assertion an un-dispatched command satisfies for free.
    • Each replacement was measured first and each carries a negative control with observed values, recorded in the commit. One finding corrected the audit itself: the RPC stall after Z is correct Neovim behaviour awaiting the second character of ZZ/ZQ — already measured and declined as a defect in KNOWN_LIMITATIONS.md — not the new defect an earlier draft of this work claimed.
  • A scenario that had never executed its assertion. table-cursor-suppression's textarea-overlay check nested its single expect inside if (hasOverlay) and then if (overlaySuppressed !== null), giving four silent-pass routes. Measured, route three fires: the overlay, its inner editor and the bridge method are all present while cmView.view is undefined, so the assertion had not run once since the scenario was written. A probe found no cmView on the overlay or any descendant, and no cursor element of any kind inside it, so the suppression claim is unobservable through this harness rather than merely unchecked. The scenario now asserts what it can establish — the overlay mounts, contains an editor, and the bridge exposes the query — and persists enableVimTextareas through saveSettings() instead of setting the field and reloading, the sequence AGENTS.md records as having shipped four settings as silent no-ops.
  • The e2e suite had no vacuity gate, and 38 tests were asserting nothing. test/specs/** received eslint-plugin-wdio only; the config said so and reasoned from eslint-plugin-mocha, which genuinely has no expect-expect — but @vitest/eslint-plugin's version is purely syntactic and applies. Enabling it reported 108, triaged rather than silenced: 41 delegate to a helper that asserts (each read before being allowlisted), 29 were missing-suite tripwires that throw (converted, since a throw is invisible to the gate), and 38 genuinely asserted nothing. All 38 are now fixed and the rule is at error across the suite with no file-scoped exemptions. assertPluginLoaded is deliberately not allowlisted though it would have cleared 28 of them: its whole body proves the plugin did not crash. Eight of the last twelve turned out not to be vacuous at all — six wait on browser.waitUntil, which throws on timeout, and one delegates to a helper that asserts three times — so they gained explicit assertions rather than rewrites, and one was a plain false positive.
    • Also enabled: no-conditional-expect (0 findings) and valid-expect with maxArgs: 1. The second is not redundant with the unit block's maxArgs: 2 — @wdio/globals' expect throws "Expect takes at most one argument" for the message form, which TypeScript accepts, and the failure then reports against every assertion in the file while naming none of them.
  • A scenario that had never executed its assertion, now executing it. table-cursor-suppression's #127 check nested its only expect two levels deep; measured, cmView.view was undefined while the overlay, its editor and the bridge were all present, so it had not run once since being written — and cmView is absent on the overlay and every descendant, so that accessor could never have worked. The view is reachable the way the product holds it, through TextareaVimManager.active.editor.cm, and the suppression claim is asserted again with the resolution itself asserted so a future refactor fails here rather than silently skipping.
  • rpc-obsidian-bridge.e2e.ts covers both RPC flakes with deterministic scenarios. Two scenarios hold the jump race open by delaying only the plugin's nvim_win_set_cursor: keeps a cross-note <C-o> cursor… and keeps a :HarpoonSelect cursor…. lets the cursor follow Neovim again when <C-o> has nowhere to go presses real keys, because nvim_input skips the cursor sync it checks. moves out of a new split that is still loading its note sends <C-w>v<C-w>h as one input, with no fault injection. The pane-focus waits now report on timeout which pane was active, the file, Neovim's mode and any notices, rather than a bare waitUntil timeout.
    • Negative controls: test/specs/rpc-obsidian-bridge-negative-controls.md, two new sections and nine sabotages, each with observed values.
  • image-embed.e2e.ts and rpc-image.e2e.ts drive image selection and :image against a real image (#218). The PNG is drawn at runtime, so no binary is committed.
    • Bundled engine: h/l entering and leaving, a count passing through, j clearing the selection, each :image action, both count forms, selecting the image under the cursor, the two error messages, and hint labels on the buttons of a selected image but not of an unselected one, plus activating Edit this block through its label.
    • Neovim backend: the resize reaching both the editor and Neovim, a count argument, one undo step, and a later Neovim edit on the same line.
    • Unit tests: image-command.test.ts pins the count rules.
    • Negative controls: twelve sabotages with observed values, in test/specs/image-embed-negative-controls.md. Two of them caught faults in the tests themselves: a sabotage applied to only one of two registration sites, and an undo scenario that never checked the grow.
  • rpc-host-edits.e2e.ts makes each kind of host edit under the Neovim backend and checks both sides hold the same note. The edits are :ob editor:toggle-bold, a ticked checkbox, an editor API edit, a change to the file on disk, and an edit made while a note is being activated, held open by delaying one of its requests. Two more scenarios check that a later Neovim edit on the same line keeps the host edit, and that u undoes it in one step.
    • Before the fix: all six then-present scenarios failed. Neovim kept alpha beta while the editor showed **alpha** beta, and after the API edit A! left the editor showing NEW alpha !beta.
    • Negative controls: not installing the listener fails all seven, plus all four in rpc-image.e2e.ts. Removing the activation deferral fails only the activation scenario, with Neovim keeping alpha beta. host-edits.test.ts fails when Neovim's own transactions are counted as host edits; that guard only saves a whole-note diff per keystroke, so no end-to-end test can observe it. Recorded in test/specs/rpc-host-edits-negative-controls.md.
  • rpc-undo-isolation.e2e.ts checks reachable undo history, not seq_last. Its beforeEach sets each note's text while connected. Those edits now reach Neovim, so seq_last and seq_cur read 2 after the reseed, with no entry left to undo to: they are the highest numbers ever used, which a reseed at undolevels = -1 does not reset. The assertion is now on undotree().entries. A reseed that keeps history fails it with 6 entries.
  • image-embed.e2e.ts opens Obsidian's image viewer through the Zoom in hint, where the runtime can. Obsidian filters the images it shows with URL.parse, which Chromium gained in 126. The 1.5.8 installer that CI runs on Linux and macOS is Chromium 120, where the button throws before opening anything. That is why it showed no effect. The scenario skips there and runs on Windows, which uses the latest installer. Measured locally on the 1.12.7 installer: the viewer opens, and without the .embed-action target the scenario fails. The whole spec passes there, 12 of 12.
  • table-reveal.e2e.ts clicks a single character in an idle table and expects the caret on it. The existing click scenario aimed at the cell's centre and accepted columns 2 to 4. The cell's start, column 2, also satisfied it, so it passed even when the text position was never read. The new scenario clicks a quarter of the way into the second b of bb and requires column 3.
    • Negative controls: never reading the text position fails it on both installers, at column 2. Breaking only the standard call fails it only on the 1.12.7 installer. Breaking only the fallback fails it only on 1.5.8. Together these show each runtime takes the branch it should.
  • split-then-move.e2e.ts sends a split and a move together under the bundled engine. It covers <C-w>v<C-w>h as keys, the same from a real F7 mapping, <C-w>s<C-w>k, the order of three window commands, and a move with nothing loading, which must still act in the same task.
    • Negative controls:
      • Running each command at once fails the three split scenarios, which end in the new pane.
      • Checking getMostRecentLeaf() instead of the active pane fails the same three.
      • Running queued commands newest first fails the order scenario.
      • Deferring a command when nothing is loading fails the same-task scenario, with changedInTheSameTask: false. Simply removing the fast path does not fail it, because the queue runs its first command before its first await.
  • handleEx could not report an ex command that throws. WebdriverIO reads a script result carrying an error key as a WebDriver error response and throws it, so the helper's ok: false result surfaced as WebDriverError: Error: … instead. The page now reports the field as failure, and the helper maps it back to error. ExCommandResult is unchanged.
    • Tests: test/helpers.ts

Documentation

  • docs/features/workspace-navigation.md says that a window command right after a split waits for the new pane to load.
  • docs/features/images.md (new) covers selecting an image with h/l, :image, its hint labels, and the Neovim backend. ex-commands.md and the keybindings reference gain :image, hint-mode.md the embed buttons, and neovim-backend.md the bridged command.
    • KNOWN_LIMITATIONS.md: records that h/l image selection is unavailable under the Neovim backend, and marks the one-way sync to Neovim fixed.
  • docs/features/neovim-backend.md says that bridged actions run in order and wait for a pane that is still loading, and that a jump keeps its own cursor. KNOWN_LIMITATIONS.md's Neovim backend status says the same.
  • The README described tables as three modes, native with and without its overlay and raw, with no mention of reveal. It now describes native and reveal, and says raw is deprecated.
    • CONTRIBUTING: its source tree listed table-format-on-exit.ts, deleted in the move to Obsidian's table editor, and now lists the reveal modules and normal-tab.ts.
    • Native cell editor note: the tables page said Enter in a native cell editor makes a line break. Measured at 1.13.7, Enter moves to the cell below and adds a row on the last one; Shift-Enter makes the line break, written as <br>.
    • ir/ar: documented as raw-only, they now say they work wherever a table is plain text, a revealed table included.
    • Documentation: README.md, CONTRIBUTING.md, docs/features/tables.md, docs/reference/keybindings.md, KNOWN_LIMITATIONS.md
  • Known limitations no longer says tables realign when the cursor leaves them. That format-on-exit behaviour, and its || shortcut, went with the move to Obsidian's table editor. The paragraph now describes when a table is realigned today. The normal-mode Tab entry moved to Resolved, and the tables page documents the typing assists, the column status and the alignment bindings.
    • Documentation: KNOWN_LIMITATIONS.md, docs/features/tables.md, docs/reference/keybindings.md, docs/features/index.md
  • The tables page listed <Leader>tj and <Leader>tk for moving a row; the bindings are <Leader>tJ and <Leader>tK, as the keybindings reference already said. The page also documents insert-mode Tab in a revealed table, tableautorealign, and the realign formatter's alignment and width rules. Known limitations records that normal-mode Tab reaches Obsidian and indents the line, where Neovim jumps forward in the jump list.
    • Documentation: docs/features/tables.md, docs/reference/keybindings.md, docs/configuration/settings.md, docs/configuration/vimrc.md, docs/configuration/lua-config.md, docs/features/neovim-backend.md, KNOWN_LIMITATIONS.md
  • docs/features/tables.md gains a "Reveal mode" section, listing only behaviour a test covers and naming what is not yet tested inside a revealed table. docs/configuration/settings.md, vimrc.md, lua-config.md and docs/features/neovim-backend.md list the new value.
  • Corrected a documentation fix from earlier in this release, which was itself wrong. Two claimed table auto-formatting features do not exist and were removed: "Separator generation: typing || … generates a correctly formatted separator row" has no implementation at all, and Vim Motions never realigns a table on its own — its realignment is reachable only from :tablerealign, <Leader>tr and =. But the accompanying claim that no realignment happens was false, and is now corrected: Obsidian's own table editor does realign a table's columns when it commits a cell edit. The first probe only moved the cursor and never edited a cell, so it could not observe the trigger — the same class of error as measuring the wrong element for horizontal scroll. Re-measured with a committed edit in a misaligned table, the header is padded to match in both native configurations. A mode that does not use Obsidian's cell editor does not get this, which is documented as such.
  • Corrected the un-reproduced widget-feature list, and removed column resizing from it. Obsidian's table widget has no column resizing: measured, there is no colgroup, no width style on any cell, and no method matching /resi|width/ among the widget's 63 prototype methods — the grip handles move columns and rows. So it is not a capability any mode can lose. The same enumeration found three features the previous count of "nine" had omitted: column and row drag-to-reorder, sort by column, and the column context menu.
  • Corrected a false claim this release had already published. KNOWN_LIMITATIONS.md and docs/features/tables.md stated that neither table mode scrolls horizontally, "measured". The measurement read .cm-table-widget's .table-wrapper descendant and the editor's scrollDOM — two elements that never scroll — rather than .cm-table-widget itself, which is where overflow-x: auto lives. Native reaches a scroll offset of 623 over the same key sequence. Re-measured by surveying every candidate container's computed overflow and scroll metrics. Only scrolloff=100 genuinely breaks horizontal scrolling, and it does so in both modes.
  • KNOWN_LIMITATIONS.md's RPC/table-overlay entry claimed the table-nav overlay "needs nothing" under RPC. That measurement covered the overlay only and never covered cell editing; the entry now records the distinction and the Escape fix.
  • KNOWN_LIMITATIONS.md described set tablewidget=raw as showing "raw markdown table syntax". It does not: the widget is hidden with CSS while Obsidian still block-replaces the source, so no .cm-line in the table's range renders at all and the table is invisible while remaining navigable by j/k. The entry now records that, points at Source mode as the supported way to edit table source, and notes that suppressing the decoration — rather than hiding the element — is measured as achievable via a highest-precedence block replace from a state field.
  • CONTRIBUTING.md was missing the entire src/vim/table/ subtree and described table-operations.ts as "row/column manipulation", which moved to table/structural.ts when that file became the realign guard. The tree now lists every module with a one-line purpose, the reveal surface's included. AGENTS.md's tree is deliberately untouched: it is scoped to Lua/RPC compatibility modules and has never covered the table surface.
  • KNOWN_LIMITATIONS.md's "Cursor and state stuck after LP→source switch during table-nav" is marked fixed, and the explanation it carried is retired rather than kept: full cleanup was never "blocked by the ViewPlugin not receiving lifecycle events during mode switches" — the plugin does receive updates, and what suppressed the release was its own early return plus a liveness check keyed on the table's document range.
  • test/specs/table-global-state-leak-findings.md records the reproduction — four arms, four runs, the full state tuples — and test/specs/table-state-leak-negative-controls.md records the three controls with observed before/after values, plus the two approaches that were measured and rejected before the working one.

Full Changelog: 1.4.0...1.5.0

Don't miss a new motions release

NewReleases is sending notifications on new releases.