Added
:files,:buffersand:recentshow 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.cssas 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.jsdoes not grow;styles.cssgrows by about 21 KB, including the licence notices. - Overrides:
--vim-motions-icon-<type>and--vim-motions-icon-<type>-colorreplace 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-dashboardandlayout-list(ISC), the icons Obsidian shows for them. The licence notices are instyles.css.
- Icons: 22 file types, from Markdown, canvas, base and Excalidraw (including
- 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-tand-bd-f, and one for every motion with a default key (-f,-w,-jand 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:
nnoremapand a plainvim.keymap.setare 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 theiriinserted text into the note.- Measured on Neovim 0.12.5:
nnoremap Q <Plug>(down)lwithnmap <Plug>(down) jrunsjand then a plainl. - Where it is fixed: in the codemirror-vim fork, which this needs at 6.4.7.
- Measured on Neovim 0.12.5:
- Names:
- 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-heightand--vim-motions-picker-results-width. - Truncation: the body class
vim-motions-picker-truncateends 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.
- Variables:
<C-y>in a picker yanks the selected item (#171).- What it yanks: from
:commands, which:obcommandwith no argument opens, the command id that:obcommand, vimrc and Lua take. From:files,:buffersand:recent, the file's path. Elsewhere, the item's label. - How: it goes through the register controller as
ydoes, so the text lands in the unnamed register and register0, and in the system clipboard whenclipboardisunnamedorunnamedplus. Then the picker closes, soppastes it. - Keys: configurable as
yankinvim.obsidian.pick_keymap. - Upgrades: settings saved before this kept their whole
pickerKeymapthrough the shallow settings merge, withoutyank, and vim setup would have thrown on the first load. Saved picker keymaps are now merged with the defaults on load.
- What it yanks: from
- 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
windowin the capture phase and stopped it, so the engine stayed in insert mode, and a second Escape was needed. A userimap <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 (
defaultPreventedon 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 noescapeleavesinsertorvim.opt.escapeleavesinsert = falseturns it off. - Not covered: pop-out windows, where listener order is not guaranteed, and the Neovim backend. Both are recorded in
KNOWN_LIMITATIONS.md.
- Before: the suggestion menu handled Escape on
Kpreviews footnotes, a secondKmoves into the preview, andqcloses it (#209, #208).- Footnotes:
Kon a footnote reference ([^1]) used to show character info. It now sends the samehover-linkObsidian's own footnote hover sends,linktext: "#[^1]", so the footnote opens in a preview. KK: a secondK, 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,Kstarts over, since Neovim closes its hover window when the cursor moves. It uses the preview'sshowEditor(), which is what a click into the preview does.q: in a preview,qin normal mode with nothing pending closes it, asqdismisses Neovim's hover window. In insert mode it types aq, and while a macro is recording it ends the recording.
- Footnotes:
- 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 theobsidian-search-match-highlightmark in theEditor.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>tbindings 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
- Plugin:
- In a revealed table, insert-mode
TabandShift-Tabmove between cells,Entermoves to the same column of the next row, andShift-Enterinserts<br>.Tabin the last cell andEnteron the last row add a row. Both wrap across rows and skip the separator.Shift-Enterwrites<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, andtable-reveal.e2e.ts's #121 scenario caught it: once a separator is typed the table is revealed, and theEnterending it dropped the next row into a new row's first cell,| | | 1 | 2 | |. mkdnflow.nvim makes itsEnternavigation 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, sorevealnow 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-Tabin the first cell does nothing, where Obsidian inserts a row above the header. A snippet that can expand or walk its tabstops keepsTab, 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
- Plugin:
tableautorealignrealigns 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 oneuundoes the edit and the realign together, like vim-table-mode'sundojoin. 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 acwwith 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
- Undo: the realign is labelled
- 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 examplerow 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
- Plugin:
- 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
- Wikilinks: a
<Leader>tal,<Leader>tacand<Leader>tarset the current column's alignment, and the alignment commands now work in a revealed table.:tablealignleft,:tablealigncenterand:tablealignrightalready 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)
- Plugin:
- Images:
handlselect an embedded image, and:imageresizes 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,
lat an image's start selects it, with Obsidian's accent outline and buttons, andlagain leaves it at its end;hdoes the same from the end. A count or pending operator moves as before, so2landdlare 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, butg+andg-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:
:imageis bridged. The resize reaches Neovim's buffer as one change, souundoes 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
- Selecting: in idle normal mode,
Changed
- Picker file names keep non-Markdown extensions, and outlines indent headings by level instead of showing
#marks (#171).- File names:
:files,:buffersand:recentlist a note without.mdand any other file with its extension, as Obsidian's quick switcher does. Measured: the switcher listsPick171,Pick171.pngandPick171.pdf, where:fileslisted all three asPick171.:recentalready did this. - Outlines:
gO,:outlineand:headingsshow each heading indented two characters per level, without#marks, as Neovim'sgOdoes (measured on 0.12.5:Top,Second,Third level, from level 1 even when a note starts at level 2).:outlinenow lists headings in document order instead of by frecency: with no history, the label tie-break had listed them deepest first.
- File names:
- 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_OPTIONSandTABLE_WIDGET_DESCRIPTION, and the deprecated option is listed last.- Plugin:
src/settings.ts(both implementations)
- Plugin:
tableWidgetMode: rawis deprecated, with a one-time notice, ahead of removal in a later release. It does not do what its name implies — measured, it hides Obsidian's widget with CSS while Obsidian still block-replaces the table's range, so the table renders as nothing, which is why Source mode is the recommended replacement. The value keeps working: it is a documented vimrc and Lua option, and a deprecation that breaks configs is not a deprecation. The legacy shapes that used to resolve to it now retarget tonativeinstead —suppressTableWidget: trueandtablewidget=always, in both the stored-settings migration and the separate vimrc/Lua option mapping — while an explicitly chosenrawis deliberately left alone.docs/configuration/vimrc.mdanddocs/configuration/lua-config.mdalso omitted a value and now list every one. The table-widget migration moved out ofmain.tsintosrc/settings-migration.ts, where its siblings already live, so it is unit-testable.- Plugin:
src/settings-migration.ts(newmigrateTableWidgetMode),src/main.ts,src/settings.ts(both implementations),src/vim/options.ts
- Plugin:
skipInTableCells()is deleted — it never skipped anything. It returned the extension unchanged plus a CSS class referenced nowhere instyles.css, so all 14 of its call sites insrc/main.tsread as keeping a subsystem out of Obsidian's per-cell table editors and none of them did. Measured, of those 14: one does per-view work a gate can reach — the scrolloff enforcer'sEditorView.updateListener, which ran 8 times inside a cell across two keystrokes, callingcoordsAtPosandgetBoundingClientRecteach time — and it is now gated onclassifySurface. Four are facet-basedgutter({...})extensions with no per-view condition, suppressed only by the existing.cm-table-widget .cm-guttersrule, which now carries a comment saying so. Four areStateFields or fold-service facets that receive anEditorStateand never see a view. One is a propertiesMutationObserverthat is inert in a cell by construction, so gating it would be unobservable and therefore unverifiable. The scrolloff gate needs no connected-view latch, unlike the treesitter bridge's: anupdateListenerhas no construction phase, and itsselectionSetguard already implies the view is in the document.- Plugin:
src/vim/scrolloff.ts,src/main.ts(14 wrappers removed),src/util/cell-editor-guard.ts(deleted),styles.css(comment)
- Plugin:
nativeremains the default table mode, deliberately.revealis 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;nativeis unchanged and is not deprecated.- A click in an idle table finds the character under the pointer with the standard
caretPositionFromPoint. It used WebKit'scaretRangeFromPoint, 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
- Plugin:
Removed
- The experimental
tableWidgetMode: ownedsurface, 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;revealmakes the active table the parent's own lines, which needs none of that bridging and does whatownedcould not — Obsidian's formatted rendering for idle tables, and Neovim's rendering inside the table. A storedownedfrom a development build migrates toreveal, andset tablewidget=ownedorvim.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
- Plugin: deleted
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,kand: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
dlabel deleted a line of the note, and Ctrl on anflabel opened search. - Flash, EasyMotion and
vim.fn.getcharstr(): Ctrl+D while flash jump input or EasyMotion labels were up, or whilegetcharstr()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.
captureKeysdoes this for hint mode, flash and EasyMotion, and thegetcharstrbroker does it while it listens. - Still differs from Neovim:
getcharstr()returns a Ctrl chord as its letter (d), not the control character ("\4").
- 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
:outlineand:headingshighlighted the wrong characters for a match. Highlights index into the label, but the label carried an indent and#marks the matcher never saw. Typingthihighlighted three characters of indent instead ofThi. 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.
xandrchange 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.
- Stale glyph: the block cursor repaints the character under it, cached by document position and row.
- 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
contenteditablediv and Add property a plaindiv, 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.
- No label: a property value is a
- 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,:outlineand: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.openInSplitnow 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. :obcommandfrom a normal-mode mapping acted on an earlier visual selection (#200). A mapping such asvim.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:obcommandrestores 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.tsrecords from the mode-change event, so anexmapthat 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.
- the command carries
- Lua function mappings stopped working after any settings change that ran
reloadFeatures(), until Obsidian restarted. The registration'sunregisterAll()redefines every action it held as a no-op.applyLuaMapsthen 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
- Plugin:
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 thenormalspecial case inapi.tsresolved the active note. Lua callbacks now record the adapter of the editor their key fired in, saved and restored like thevim.vcontext, andvim.cmd()runs there. Timers and autocmds keep using the active note.- Plugin:
src/lua/api.ts(getCallbackAdapter),src/main.ts
- Plugin:
- 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
windowbefore 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
- Plugin:
- 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'shide()keeps a child that still holds the focus. The guard now finds the popover holding the focused editor by walkingchildHovers, and hides that one. Whether the preview beneath then stays is Obsidian's own hover logic.- Plugin:
src/vim/escape-guard.ts
- Plugin:
- Normal-mode
Tabindented 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 (nativemode),Tabstill moves to the next cell, as #136 relies on: the mapping marks itselffromTab, and there the key is run through the cell editor's own keymap with CodeMirror'srunScopeHandlers, rather than reimplementing Obsidian's cell move. Under Obsidian's own Vim key bindings the plugin registers no mappings, soTabstill reaches Obsidian there. Related to #206.- Plugin:
src/main.ts,src/vim/normal-tab.ts(new),src/types/vim-api.d.ts
- Plugin:
:tablerealignplaced the content of right- and centre-aligned columns at the left edge, where Obsidian places it by the separator's colons. The text fallbacks inrevealshare the same formatter. Switching a table betweennativeandrevealtherefore rewrote those columns back and forth, because Obsidian's editor realigns its way on every commit.realignTableLinesnow pads exactly as Obsidian'sTableEditor.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
- Plugin:
- A legacy
tablewidgetvalue from a vimrc or Lua was written into the setting raw.cursor,embedded,offandalwayswere stored as-is and corrected tonativeonly 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
- Plugin:
- 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
- Plugin:
- Escape could not cancel a pending Neovim command while a table cell editor was open, stalling the RPC pipeline with no way out.
TableNavController.installCellEscapeCaptureregistered adocument-capture keydown handler callingstopImmediatePropagation(), which destroyed the event before the key-delegation listener on the editor could forward<Esc>tonvim_input. Any Neovim command awaiting its second character — afterZ,d,g,", a count or an operator — therefore could not be cancelled, and RPC API requests stalled indefinitely. Measured: the identicalZthen Escape sequence recovers in 3 ms with no cell editor open and never recovers with one, across two Escapes. The capture now skipsstopImmediatePropagation()while a backend other than the bundled fork owns keys, resolved throughisExternalBackendActive(), which the connection lifecycle already maintains. Behaviour with RPC disconnected is unchanged, so the documented two-stepcell-edit(insert)→cell-edit(normal)→table-navEscape transition still holds for the bundled engine. The underlying stall is normal Neovim behaviour awaiting a command's completion and is not table-specific — it reproduces with no table involved — so it is not treated as a defect. - A table-nav entry scheduled across an edit could take the editor down.
scheduleEntry()captured the table's range and itsentryTimer !== nullguard stopped any later update replacing the pending candidate, so an edit inside theENTRY_DEBOUNCE_MSwindow left the capturedtopast the end of the document. Nothing caught it: the only validation wascursorInRange(from, to), which still passes because the cursor is numerically inside the stale span. The staletableTothen reachedtableNavStateField, where the next document-changing transaction maps it and throwsPosition 41 is out of range for changeset of length 37from inside aStateFieldupdate, which takes the field and the editor with it. Reproduced byca|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 pastdoc.length.- Plugin:
src/vim/table-nav-controller.ts
- Plugin:
- 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: nativeactivates 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 atviewSuppressed: true,overrideCount: 1, the cursor layerdisplay: nonewith zero children, and a followingjnot moving the head at all. Leaving with realkpresses was always correct —overrideCount: 0and one visible cursor — which is why this survived: every keyboard path hides it.table-cell-cursor-guardcould not compensate either, because itsupdate()early-returns while nav is active. Nav'supdate()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 aViewPlugin.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 existingisCellVimIdletreats 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 dispatchesexitTableNavbefore 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'sEditorView-keyed suppressionWeakMap, whichsetPluginSettingAndReloaddoes not reset.test/specs/table-cursor-surface-parity.e2e.tsis no longer skipped and now also asserts that the caret is not repositioned on the way out.- Plugin:
src/vim/table-nav-controller.ts
- Plugin:
- The treesitter bridge allocated a WASM parser and retained a syntax tree for every table cell editor Obsidian opens. Obsidian's Live Preview table editor creates a separate CM6
EditorViewper cell, so every extension registered throughregisterEditorExtension()is instantiated again for a one-line cell document — and the bridge is the most expensive of them. Measured against a real Obsidian 1.13.7: with the gate removed, a cell editor holdingaareports a published tree; with it in place it reports none, while the parent editor keeps its own. Two earlier attempts tried to keep the extension out of the view's configuration withStateEffect.appendConfigdispatched from aViewPluginand installed it nowhere — main editors included — passing their cell-side assertions for exactly that reason; a parent control is now mandatory in any test of this. The branch also cannot be taken where it looks like it belongs. A cell editor is constructed detached (measured atisConnected: false, no.cm-table-widgetancestor, document"aa"), so a surface classified in the plugin's create function readsdocumentfor every cell and gates nothing; the decision and the parse are deferred to the first update on a connected view, which is the mechanismtable-cell-cursor-guard.tsalready relies on. The latch fails in the safe direction — a surface it cannot classify keeps its bridge rather than silently losing one. The four gutters are not covered: they are built with CodeMirror's facet-basedgutter()API, which has no per-view condition, and are hidden with CSS in cells rather than excluded.- Plugin:
src/util/surface-gate.ts(newclassifySurface),src/treesitter/bridge.ts(GatedTreeSitterBridge),src/main.ts(getTreesitterSurfaceReport())
- Plugin:
- A realign is refused while the cursor is in insert mode. Realignment replaces the whole table, and three facts make that dangerous rather than untidy mid-insert: the fork's adapter exposes changed regions with no meaningful origin, insert recording accepts
origin === undefined, and a table-spanning replacement is therefore eligible to enterlastInsertModeChanges— after which.replays a formatter edit. Both realign paths now share one guard.- Plugin:
src/vim/table-operations.ts,src/motions/tables.ts
- Plugin:
- The cursorline could tint a line inside Obsidian's table cell editor, and the claim that it could not was wrong. An earlier entry in this release stated the defect "does not exist", on a local measurement of zero
.vim-motions-cursorlineelements inside.cm-table-widgetin all threecursorlineoptmodes. CI measured one, on Linux, macOS and Windows alike, so whether the decoration applies is environment-dependent and the measurement should never have been generalised. Thelineandbothmodes are aStateField, which receives anEditorStateand never sees a view, soclassifySurfacecannot withhold them the way it withholds the scrolloff listener; the fix neutralises the background instead.display: noneremains wrong for the reason already recorded —buildCursorlineDecorationsputs the class on the real line viaDecoration.line, so hiding the element hides the cell's text.- Plugin:
styles.css
- Plugin:
- The vim fork was hiding the caret of an editor mounted inside another, across the editor boundary.
BlockCursorPlugin.update()setsdisplay: noneon every.cm-cursorLayer:not(.cm-vimCursorLayer)it finds in its ownscrollDOM— right for its own view, wrong for a separateEditorViewmounted inside thatscrollDOMand draws its caret with plaindrawSelection(). Measured on a table-cell editor mounted that way: its layer held one caret, was focused, and was inlinedisplay: none, which is also why every probe of that caret reported0x0— a hidden element has no box. The fork now skips layers whoseclosest('.cm-editor')is not its ownview.dom, in bothupdate()anddestroy(); the guard is generic and mentions no table. Verified against a local fork build (display: block, a1x19caret inside the cell's box) with a negative control that restoresdisplay: noneand0x0, and the fork's own suite green across four partitions at 1622 passing. Shipped in@saberzero1/codemirror-vim@6.4.4, which this release consumes; the regression spec runs unskipped.- Fork:
~/Repos/codemirror-vim/src/block-cursor.ts(ownsLayerguard inupdate()anddestroy(), documented in the fork'sDIFFERENCES.md) - Plugin:
package.json(alias bumped to^6.4.4)
- Fork:
- 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'supdate()early-returned unlessselectionSet || 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 becausecursorInAnyTableis 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 suppressionabandonNavalready knew how to clear. (#167)- Plugin:
src/vim/table-cell-cursor-guard.ts(re-evaluate while suppressed),src/vim/table-nav-controller.ts(widget-goneabandon reason, orphaned-focus reclaim inabandonNav)
- Plugin:
- 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:HarpoonSelectopen 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 inrpc-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)
- Plugin:
- Neovim backend:
<C-w>v<C-w>hstayed 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 offocuses the expected pane in all four directions.- Plugin:
src/rpc/obsidian-feature-bridge.ts(resolveActiveEditor, the action turn inonNotification)
- Plugin:
- Neovim backend: edits Obsidian makes to the note now reach Neovim. Sync ran only from Neovim to the editor. A ticked checkbox, an
:obcommand 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 turnedNEW alpha betaintoNEW alpha !beta. Every edit that does not come from Neovim's line events now goes to Neovim's buffer as one change, souundoes 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>hstayed 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.tskeeps 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.tspins the conflict-check rows toWORKSPACE_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
- What it enforces: every collision is classified in
hint-mode-ctrl-hotkeys.e2e.tsandcapture-obsidian-hotkeys.e2e.tspress 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
dlabel and anflabel. 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:
captureKeysnot holding the keymap fails the hint, flash and EasyMotion scenarios;- the
getcharstrbroker not holding it fails only thegetcharstrscenario; - 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 reachfonly past 78 targets, and CI's smaller window showed fewer, so thefscenario 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.tspins 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
- Hint mode: a
picker-icons.e2e.tsreads the icons:filesdraws 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.
- a distinct mask per type, in its own colour: the PDF in
- Negative controls:
- rendering no icon element fails all three;
- removing the icon CSS fails all three;
- removing the
.excalidraw.mdrule fails the first, withmd.
- Unit tests:
picker-icons.test.tschecks that every type's data URI decodes to an SVG and has an overridable colour, that common vault extensions map to their icons, andpickerFileType. 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
- Scenarios:
easymotion-plug.e2e.tsmaps F-keys to<Plug>(easymotion-...)names and presses them (#205).- Scenarios:
bd-wlabels words on both sides and jumps back; it still works afterd; it is reached from a plainvim.keymap.set(non-recursive) and fromnmap. - 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'svim_noremap_plugtest 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)
- Scenarios:
picker-style.e2e.tsreads 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.tspins 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.tspresses<C-y>in real pickers (#171).- Scenarios: a command from
:obcommandyanks its id into"and0and closes the picker; a file from:filesyanks its path; a keymap saved withoutyankstill 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
yankTextfails the command scenario with the labelToggle 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)
- Scenarios: a command from
picker-names-outline.e2e.tsreads the real pickers and thegOmodal (#171).- Scenarios:
:filesand:bufferswith a note, a PNG and a PDF of the same name;gOand:outlineon a note with headings at three levels; the:outlinematch highlight. - Before the change: all five failed. The file pickers showed
Pick171three times, the outlines showed#marks, and the highlight covered three spaces. - Negative controls:
- putting
:outlineback on frecency fails only its order; - removing the indent rule from
styles.cssfails only its indentation.
- putting
- Updated spec:
files-buffers-non-md.e2e.ts(#169) expected the strippedTestCanvas, and now expectsTestCanvas.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
- Scenarios:
escape-leaves-insert.e2e.tspresses 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> Xthat 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
defaultPreventedcheck fails the mapping scenario withabXX, because the injected<Esc>ran the user's mapping a second time.
- Tests:
test/specs/escape-leaves-insert.e2e.ts(new)
- Scenarios: one Escape closes them and leaves insert mode, keeping the text; with the option off, the first Escape only closes them; a user
keyword-preview.e2e.tspressesK,KKandqagainst real page previews (#209, #208).- Scenarios:
Kon a footnote;Kon a link (control);KKfocusing the preview andqclosing it;Kafter a motion not focusing;qtyping in insert mode;qending a recording;qin the note still recording a macro (control). - Before the change:
Kon[^1]showed character info. - Negative controls:
- removing the footnote branch fails the footnote scenario;
- deleting the
KKcall fails the focus scenario and theqscenarios that build on it; - dropping the unmoved-cursor rule fails only the motion scenario;
- an inert
qlistener 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
KKcontrol written asfocusKeywordPreview(view) && falsestill called the function, and passed for that reason. With the call actually removed, the scenario fails. - Unit tests:
footnote-ref.test.tspins 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)
- Scenarios:
- A Neovim download that times out no longer kills a CI shard.
- Before:
scripts/install-neovim.ps1calledInvoke-WebRequestonce, 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-MaximumRetryCountretries 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.shnow passes curl--retry 3 --retry-all-errors, asfetch-test-plugins.shdoes. - Checked locally:
- against a closed port,
Get-Archive, taken from the script itself, warned, retried and then threw; - the real
nvim-win64.zipdownloaded whole; install-neovim.shinstalled Neovim and passed its API-level check.
- against a closed port,
- Scripts:
scripts/install-neovim.ps1,scripts/install-neovim.sh
- Before:
animated-cursor-edits.e2e.tsreads 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:
xandrunder 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)
- Scenarios:
hint-mode-properties.e2e.tsdrives 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
contenteditableselector 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.tsopens line-bearing picker results with the real picker keys (#201). Every scenario slowsWorkspaceLeaf.openFileby 300 ms, the condition the reporter's machine met and this vault did not.- Scenarios:
:livegrepin a new tab and a vertical split;:grepand:headingsin a new tab;:livegrepin a background tab with Always focus new tabs off;:livegrepin 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
openInSplitfails 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.
- dropping the line in
- Tests:
test/specs/picker-split-cursor.e2e.ts(new)
- Scenarios:
obcommand-stale-selection.e2e.tspresses 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
betaagainst 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.
- treating every selection as fresh fails both normal-mode scenarios with
- Tests:
test/specs/obcommand-stale-selection.e2e.ts(new)
- Normal mode: right after Escape, and after moving to another line. Both captured
lua-keymap-reload.e2e.tsandtextarea-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! xscenario: keeps a note open behind the text area. Against the old path, the text area stayedarea textand the note lost its first character. - The
obcommandscenario: 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.tsuses 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.tspresses a real Ctrl+I. The test vault'shotkeys.jsonnow unbindseditor:toggle-italics, as it already didswitcher: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
- Tests:
hover-popover-escape.e2e.tsopens 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)
- Nested preview: fails against the old guard, with the focused preview,
lua-plugin-mini-commentno longer checks thegcmapping 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
gcpassed in the same run. - Why the old check was wrong:
setup()mapsgctwice over: aslua-action-eager-Nwhile the config loads, and aslua-action-NwhenapplyLuaMapsruns on the nextreloadFeaturesor 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,
gcstayed mapped, because Lua is not enabled at startup there. - New check: the scenario waits up to 5 s for
gcmapped 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
- 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
Enter,Shift-Enter, the row text objects and the rendered table settings.EnterandShift-Enter: fivetable-reveal-editing.e2e.tsscenarios, with six sabotages, each failing its own:Enterunbound: 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-Enterunbound: the row was split with no<br>;Enterswallowed outside tables: no new line;- no end-of-row rule: the row typed after
Enterlanded 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 hiddenselect.is-measuringObsidian adds to size it. Against the previoussettings.tsit 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 intest/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.
- escape removed: the alias split the cell,
- 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-modeTabreached column 9.- Existing motions:
i|taking the opening pipe yanked| aa;[rwithout 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
Tabis now consumed by its mapping before any keymap runs, so the old test could no longer fail. - Caught regression:
table-nav-disabled.e2e.tscaught the first version of the<Tab>mapping consumingTabin 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:
setColumnAlignmenthas 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:tablerealignreports "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
- Typing and alignment:
- 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, becauseTabis a keymap binding thatVim.handleKeynever reaches. Ten sabotages each failed their scenario:- insert-mode guard removed: normal-mode
Tabreached column 9; - separator not skipped:
Shift-Tabstopped 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.typelabel:uundid only the realign, leaving| aaxxxx | 11 |; - row guard removed:
extrawas dropped; - no
cwseed: 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.
- insert-mode guard removed: normal-mode
- 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
Taband realign scenarios. Both failed until the adapter was resolved througheditorView.cm:Tabinserted a character at column 3, and the table stayed as typed. - The Neovim reveal spec asserts that insert-mode
Tabreaches Neovim. Itssofttabstopfolds 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 resetvimrcLoaded/vimrcLoadingto 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 ranreload-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 newconfigReloadSettledForTest()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, got0, and expected"graph", got"markdown"forgmap-vimrc; expected"both", got"number"forcursorlineopt-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-configurationkeeps 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 throwskill ESRCH. That is howrpc-editor-optionsfailed a macOS shard after all three of its tests had passed. The sharedkillIfAlivekills 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 doesrpc-latency, whose cleanup used to swallow every error. Twelve localpidIsAlivecopies 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 aNaNpid pass without the expectedERR_INVALID_ARG_TYPE. - Tests:
test/specs/rpc-process.ts(new),test/unit/rpc/kill-if-alive.test.ts(new), 15test/specs/rpc-*.e2e.tscleanup hooks
- Unit test, written red first: a reaped process. Under the old bare kill it threw
- The Windows end-to-end shards run Obsidian's latest installer; Linux and macOS keep
earliest. All 36 Windows shards failed withnot connected to DevTools. On the windows-2025 20261021 runner image, the 1.5.8 installer's GPU process (Electron 28) crashes withSTATUS_ACCESS_VIOLATIONabout 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 inrevealbefore 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 fromrevealback tonativeleaves table-nav working, whichowneddid 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
- Tests:
- Twelve more
revealscenarios: 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)
- Tests:
- Eighteen end-to-end scenarios for
tableWidgetMode: reveal, four more under the Neovim backend, and 29 unit cases, with the negative controls intest/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 —d3jfrom above the table — that passed with the reveal disabled. The unit tests drive the extender through a realEditorStateagainst 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
- Tests:
- Six scenarios for the
rawdeprecation plus nine unit cases for the migration, with six negative controls intest/specs/table-raw-deprecation-negative-controls.md. Two of those controls did not fire at first and each exposed a different hole: the once-per-session scenario used document edits as its stimulus, but the notice path runs on a settings reload, so the flag was never exercised; and nothing covered the vimrc/Lua option path at all, so thealwaysmapping change insrc/vim/options.tswas unverified until twohandleExscenarios were added. Controls 3 and 4 are deliberately asymmetric — one breaks the legacy cases and leaves the explicitrawpassing, the other does the reverse — because retargeting a legacy value and migrating a user's explicit choice are separate decisions. - Two specs for the cell-editor guard, plus six negative controls in
test/specs/table-cell-guard-negative-controls.md. The scrolloff spec asserts a count rather than geometry, because a one-line cell editor hasscrollHeight ≈ clientHeightand the enforcer's own>= 1threshold means it never writesscrollTopthere — "it did not scroll" holds whether or not the listener ran. Its parent half is mandatory: a cell count of zero is identical whether the gate withheld the listener or it never installed anywhere, which is precisely how two earlier surface-gate attempts passed while doing nothing. The cursorline spec pins an absence, so its control inverts the assertion, and each scenario separately proves the selector matches something by asserting the parent's highlight with the cursor outside a table. - One control is recorded as not applicable rather than given a passing substitute: the "classification moved into a
ViewPlugincreate function" sabotage has nothing to act on when the gate lives in anupdateListener, which has no construction phase. - A real-Obsidian gate measurement for the treesitter surface branch (
test/specs/table-surface-gate.e2e.ts), asserting in one run that a live table cell editor has no syntax tree, that it is classifiedtable-cell, and that the parent editor still has its own — the last being the only reading that distinguishes "withheld from cells" from "never installed", which is how both earlier attempts passed while doing nothing. A precondition asserts a cell editor actually exists, without which the two cell assertions pass trivially. The unit side (test/unit/fold/bridge-metadata.test.ts,test/unit/util/surface-gate.test.ts) covers the detached-then-connected sequence the product actually performs, against real WASM. Seven negative controls are recorded intest/specs/table-surface-gate-negative-controls.md, including the one that caught a flaw in a test fake rather than in the product: an earlierclosestanswered while detached, which let the cell test survive a sabotage that should have broken it. - A 12-scenario characterisation suite for RPC behaviour around table cell editors (
test/specs/rpc-table-cell-diagnosis.e2e.ts), crossing key type against table context and probing a fast API (nvim_get_mode), a non-fast one (nvim_win_get_cursor) and renderer liveness. It exists because the defect was first reported with a mechanism that source refutes:enqueueInputdoes not chain keys onprepareKeyInput(), which isawait this.frontmatterFold.sync()and returns immediately once the mode matches. The suite's decisive control presses the same prefix key with no table involved and reproduces the stall, which is what established that the stall and the unrecoverability are two different things. Acceptance is probed at a 35 s deadline, past the 30 sREQUEST_TIMEOUT_MS, so a request left pending cannot satisfy it; negative-control evidence is recorded intest/specs/rpc-table-cell-diagnosis-negative-controls.md. - The table formatter is now pinned against Obsidian's own, nine specimens compared byte-for-byte with a live
TableEditor.rebuildTable()innativemode, plus eleven unit cases for fast feedback.realignTableLineshad no unit coverage at all beforehand. The planned change this guards — adisplayWidth()helper replacingString.length— was not made, because measuring the oracle showed Obsidian pads by UTF-16 length too (日本語→ column width 3, not 6; a ZWJ emoji → 8, not 2), so the helper would have broken the byte-for-byte match the same step required. - The first version of that spec was vacuous and the negative control is what caught it. It drove
:tablerealignagainst the document, and innativemode Obsidian's widget reformats the table immediately afterwards — so the document converged on the oracle whatever our formatter produced, and a deliberately wrong width model reported 9 of 9 passing with the sabotage verified live in the source. The spec now calls the pure function directly through a newformatTableLines()accessor, after which the same sabotage fails exactly the four specimens where the two width models disagree and keeps the five where they agree. Two habits made it findable: verifying the sabotage was present before believing its result, and predicting the per-case isolation in advance so an all-green run was recognisable as wrong. - 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, sinceview.composingstaysfalseafter a syntheticcompositionstart— CodeMirror setscomposingto0there and the getter tests> 0. That precondition assertion is what turned a would-be passing guard into a visible finding. isolateHistorywas not used and is not needed.@codemirror/commandsis not a declared dependency, only a transitive one, and ships its own nested@codemirror/statethat TypeScript rejects outright; the annotation is anAnnotationTypeinstance, so a copy of it is not the one the host's history reads and could compile while silently doing nothing. Measured, the behaviour it was meant to buy already holds once focus is correct: oneuundoes the realign and leaves the typed character, a second removes it.- 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
realignTableLinesrebuilds the separator from the data rows' column count — but it does not rebuild the alignment markers, which it reads back out of it. The e2e fixture is|------|------|, every column unaligned, so there is nothing to misplace; the unit fixture is|:---|---:|:--:|and fails exactly the two alignment cases. The e2e proves cell counts, the unit tests prove alignment, and neither substitutes for the other. - The 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 —
nativeandreveal. It exists because the existing suite structurally cannot catch this regression class: counted across the four prior fixes' specs, there are fourcursorOnTableLine === falseassertions and oneparentCursorVisible === false, and not one asserting that a cursor is drawn. A suite built only from absences is satisfied by a cursor that renders nowhere, and every one of those specs pinstableWidgetMode = '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 undernative, the parent's underreveal, where it is the only caret. Findings intest/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 runsnative, where the defect was worst. It asserts painted cursors and that a realjstill moves the head, not only the suppression flags — measured necessary rather than assumed: reverting the cursor-guard fix leavesviewOverrideCountat a healthy0, because the view reconfigure fires the plugin'sdestroy(), 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.tsasserted the defect, and were inverted.expect(fork.keyInterceptActive).toBe(true)after an LP→Source switch, under adescribetitled "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, becausemain.ts'skeyInterceptSafetyHandlerclears 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-editassertedlongProbe.deadlineMs > 30000— the45000it passed in twelve lines earlier — whilerecoveredEventuallywent to JSON unread; four pipeline-health readings were collected and only the pre-table control asserted.tables.e2e.tsasserted a direction for six cell motions and never a destination —]|landing on theBcell isch 6, but> 3also accepted the delimiter at 4 — and]r/[r, named "same column", never compared the two columns.table-nav-modeasserted a|-line count foro,Oanddd; measured,oandOproduced byte-identical documents from their respective cursors, so the pair could not distinguish the operations however the assertion was phrased, andOnow runs from the second data row. "modifier combos should not be swallowed" pressedj. Four scenarios claiming a key works without the nav overlay checked the overlay before the key and not after, or never. Andtables.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
Zis correct Neovim behaviour awaiting the second character ofZZ/ZQ— already measured and declined as a defect inKNOWN_LIMITATIONS.md— not the new defect an earlier draft of this work claimed.
- 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
- A scenario that had never executed its assertion.
table-cursor-suppression's textarea-overlay check nested its singleexpectinsideif (hasOverlay)and thenif (overlaySuppressed !== null), giving four silent-pass routes. Measured, route three fires: the overlay, its inner editor and the bridge method are all present whilecmView.viewisundefined, so the assertion had not run once since the scenario was written. A probe found nocmViewon 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 persistsenableVimTextareasthroughsaveSettings()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/**receivedeslint-plugin-wdioonly; the config said so and reasoned fromeslint-plugin-mocha, which genuinely has noexpect-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 thatthrow(converted, since a throw is invisible to the gate), and 38 genuinely asserted nothing. All 38 are now fixed and the rule is aterroracross the suite with no file-scoped exemptions.assertPluginLoadedis 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 onbrowser.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) andvalid-expectwithmaxArgs: 1. The second is not redundant with the unit block'smaxArgs: 2—@wdio/globals'expectthrows "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.
- Also enabled:
- A scenario that had never executed its assertion, now executing it.
table-cursor-suppression's #127 check nested its onlyexpecttwo levels deep; measured,cmView.viewwasundefinedwhile the overlay, its editor and the bridge were all present, so it had not run once since being written — andcmViewis absent on the overlay and every descendant, so that accessor could never have worked. The view is reachable the way the product holds it, throughTextareaVimManager.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.tscovers both RPC flakes with deterministic scenarios. Two scenarios hold the jump race open by delaying only the plugin'snvim_win_set_cursor:keeps a cross-note <C-o> cursor…andkeeps a :HarpoonSelect cursor….lets the cursor follow Neovim again when <C-o> has nowhere to gopresses real keys, becausenvim_inputskips the cursor sync it checks.moves out of a new split that is still loading its notesends<C-w>v<C-w>has 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 barewaitUntiltimeout.- Negative controls:
test/specs/rpc-obsidian-bridge-negative-controls.md, two new sections and nine sabotages, each with observed values.
- Negative controls:
image-embed.e2e.tsandrpc-image.e2e.tsdrive image selection and:imageagainst a real image (#218). The PNG is drawn at runtime, so no binary is committed.- Bundled engine:
h/lentering and leaving, a count passing through,jclearing the selection, each:imageaction, 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.tspins 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.
- Bundled engine:
rpc-host-edits.e2e.tsmakes 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 thatuundoes it in one step.- Before the fix: all six then-present scenarios failed. Neovim kept
alpha betawhile the editor showed**alpha** beta, and after the API editA!left the editor showingNEW 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 keepingalpha beta.host-edits.test.tsfails 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 intest/specs/rpc-host-edits-negative-controls.md.
- Before the fix: all six then-present scenarios failed. Neovim kept
rpc-undo-isolation.e2e.tschecks reachable undo history, notseq_last. ItsbeforeEachsets each note's text while connected. Those edits now reach Neovim, soseq_lastandseq_curread 2 after the reseed, with no entry left to undo to: they are the highest numbers ever used, which a reseed atundolevels = -1does not reset. The assertion is now onundotree().entries. A reseed that keeps history fails it with 6 entries.image-embed.e2e.tsopens Obsidian's image viewer through the Zoom in hint, where the runtime can. Obsidian filters the images it shows withURL.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-actiontarget the scenario fails. The whole spec passes there, 12 of 12.table-reveal.e2e.tsclicks 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 secondbofbband 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.tssends a split and a move together under the bundled engine. It covers<C-w>v<C-w>has keys, the same from a realF7mapping,<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.
- Negative controls:
handleExcould not report an ex command that throws. WebdriverIO reads a script result carrying anerrorkey as a WebDriver error response and throws it, so the helper'sok: falseresult surfaced asWebDriverError: Error: …instead. The page now reports the field asfailure, and the helper maps it back toerror.ExCommandResultis unchanged.- Tests:
test/helpers.ts
- Tests:
Documentation
docs/features/workspace-navigation.mdsays that a window command right after a split waits for the new pane to load.docs/features/images.md(new) covers selecting an image withh/l,:image, its hint labels, and the Neovim backend.ex-commands.mdand the keybindings reference gain:image,hint-mode.mdthe embed buttons, andneovim-backend.mdthe bridged command.KNOWN_LIMITATIONS.md: records thath/limage selection is unavailable under the Neovim backend, and marks the one-way sync to Neovim fixed.
docs/features/neovim-backend.mdsays 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,
nativewith and without its overlay andraw, with no mention ofreveal. It now describesnativeandreveal, and saysrawis 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 andnormal-tab.ts. - Native cell editor note: the tables page said
Enterin a native cell editor makes a line break. Measured at 1.13.7,Entermoves to the cell below and adds a row on the last one;Shift-Entermakes 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
- CONTRIBUTING: its source tree listed
- 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-modeTabentry 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
- Documentation:
- The tables page listed
<Leader>tjand<Leader>tkfor moving a row; the bindings are<Leader>tJand<Leader>tK, as the keybindings reference already said. The page also documents insert-modeTabin a revealed table,tableautorealign, and the realign formatter's alignment and width rules. Known limitations records that normal-modeTabreaches 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
- Documentation:
docs/features/tables.mdgains 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.mdanddocs/features/neovim-backend.mdlist 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>trand=. But the accompanying claim that no realignment happens was false, and is now corrected: Obsidian's own table editor does realign a table's columns when it commits a cell edit. The first probe only moved the cursor and never edited a cell, so it could not observe the trigger — the same class of error as measuring the wrong element for horizontal scroll. Re-measured with a committed edit in a misaligned table, the header is padded to match in bothnativeconfigurations. 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.mdanddocs/features/tables.mdstated that neither table mode scrolls horizontally, "measured". The measurement read.cm-table-widget's.table-wrapperdescendant and the editor'sscrollDOM— two elements that never scroll — rather than.cm-table-widgetitself, which is whereoverflow-x: autolives. Native reaches a scroll offset of 623 over the same key sequence. Re-measured by surveying every candidate container's computed overflow and scroll metrics. Onlyscrolloff=100genuinely 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.mddescribedset tablewidget=rawas showing "raw markdown table syntax". It does not: the widget is hidden with CSS while Obsidian still block-replaces the source, so no.cm-linein the table's range renders at all and the table is invisible while remaining navigable byj/k. The entry now records that, points at Source mode as the supported way to edit table source, and notes that suppressing the decoration — rather than hiding the element — is measured as achievable via a highest-precedence block replace from a state field.CONTRIBUTING.mdwas missing the entiresrc/vim/table/subtree and describedtable-operations.tsas "row/column manipulation", which moved totable/structural.tswhen 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.mdrecords the reproduction — four arms, four runs, the full state tuples — andtest/specs/table-state-leak-negative-controls.mdrecords 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