Skip to content

Release 2.54.0: search creates the note you typed, the editor opens where you were reading, and Harper keeps its dictionary - #833

Merged
adibhanna merged 11 commits into
mainfrom
v2.54.0
Sep 22, 2026
Merged

adibhanna merged 11 commits into
mainfrom
v2.54.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

ZenNotes 2.54.0

One feature and seven fixes from the issue tracker. Three from @vinivinoo (#827, #825, #826), two from @uNyanda (#829, #828), one each from @saran-ncsu (#822) and @diazkev314 (#831), and one filed by the maintainer from the Android app (#820). Every issue is closed with a commit-linked comment.

Features

Fixes

Verified before this PR

  • npm run typecheck 7 of 7, nothing from cache; npm run test:run: shared-domain 1,685, app-core 2,644 (1 skipped), desktop 891 (4 skipped).
  • cd apps/desktop && npm run build:prod fresh, then npm run pack; the packaged app launched with both stores isolated: CDP page target in 1.5 s, version 2.54.0.
  • Smoke against the release build: test:vim-editor, test:sidebar-vim, test:editor-improvements, all green with no renderer console errors.
  • Every fix reproduced on the previous build first and verified in the built app over CDP with real key events; two captioned clips recorded ([Feature Request] Create Notes from Search #826, Preview to Edit Mode #822).

Docs: in-app help updated in the commits; the website mirror is the branch website/docs-2.54, merged with the release.

adibhanna and others added 11 commits September 21, 2026 10:49
… is followed (#820)

From the Android app (#820): in Reading mode, tapping a
`[[Note#Heading]]` wikilink opened the target, but the status bar's left
slot kept showing `Note#Heading` on the new note, and kept it until the
next tap put a different target there. On desktop the same slot behaves
like a browser: the target shows while the mouse rests on a link and
goes away when it leaves.

A tap reaches the page as `touchstart, touchend, mouseover, mousemove,
mousedown, mouseup, click`, with no `mouseleave` ever. The `mousemove`
puts the target in the shared hovered-link store, and the only things
that cleared it were the article's `onMouseLeave` and the preview root's
unmount cleanup. A finger never leaves, and since android#74 the preview
stays mounted when a heading link is followed in Reading mode, so
neither fired. Nothing else guarded a note changing under a resting
pointer.

Two clears, the two the report asked for. Following a link clears the
slot: the preview root's click handler does it for every anchor
(wikilink, note link, hashtag, web, in-page), the editor does it on the
rendered-wikilink mousedown and in both `followLinkTarget` branches. And
the status bar drops the target whenever the active note's path changes,
keyed on the path rather than the note object so typing in a note does
not blank a live hover. A real pointer that is still over a link puts
its target back on the next move, so desktop hover reads the same as
before.

Deliberately not done: gating the slot behind `pointer: coarse`. The
report accepted the brief flash during a tap, and the two clears above
are what made the stale target go away. The wikilink hover card is a
separate surface the Android shell already hides with its own
`pointer: coarse` rule and is left alone.

Verification: `StatusBar.test.ts` gains a "hovered link slot" pair (a
same-path re-render keeps the target, a path change drops it; the second
is red on the old code and the first guards the keying), and the new
`Preview-hovered-link.test.ts` drives a rendered wikilink through
mousemove and click in jsdom (target set, then cleared, and
`openWikilinkTarget` called with `Alpha plan.md` and
`Alpha plan#Milestones`; red on the old code). Reproduced and re-checked
in the built app over CDP with the same synthetic tap sequence: before,
the footer read `Alpha plan#Milestones` on the opened note; after, it is
empty in Reading mode and in edit mode, and desktop hover still shows on
enter, clears on leave, and is empty after a click.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…change (#829)

From @uNyanda on Linux (#829): words taught to Harper with `zg` were
underlined again after updating and restarting ZenNotes, and had to be
added a second time. The update never touched them on disk; vault.json
keeps `harper.words` and `harper.ignoredLints` through every normalizer.
The loss happened at runtime, in the one live Harper session, and the
next `zg` or `zG` then wrote that session's export over the vault's
list, which is when the words left vault.json for good.

Two ways a session ended up without the vault's words. A dialect
change: harper.js answers `setDialect` by freeing the Linter and
building a new one, and the dictionary and the ignore list live inside
the Linter, so `configure()` (which set the dialect and never imported
them back) emptied the session on the spot, from Settings or from an
edit to `harper_dialect` in config.toml. Reproduced live: after the
switch every vault word was underlined, and a `zg` on a third word left
vault.json with only that word. And the boot race: an enabled setting
warms the session 1.5 s after mount, at idle, and when the vault was
still loading (the store sets `vault` and `vaultSettings` together at
the end of `init()`) the session was built from empty settings; the
old reconcile then recorded the words that landed during the 15 MB
compile as already seen, without importing them. That is the shape of
the report, a restart with the words underlined and vault.json intact
until the next `zg`, and the first launch after an update is a slow
one. Which of the two the reporter hit cannot be told from the report;
both are closed here.

Three changes. `configure()` compares the linter's dialect with the
requested one and, only on a change, exports the dictionary and the
ignore list, switches, and imports them into the new Linter. The
runtime records what a session is built from the moment the build
starts, before the compile is awaited, so anything the store loads
meanwhile reads as a change to import; the first-pass "only record what
it already holds" special case is gone, and a failed load resets both
markers. `persist()` writes the union of what vault.json holds and what
the session exports (`mergeHarperVaultState`, base order first), never
the shorter list, and when the vault knew more it teaches the session
the difference so the re-lint after a `zg` clears every word the vault
has. The union is safe because nothing in the app removes a dictionary
word or un-ignores a suggestion; a "remove word" feature would have to
revisit that helper. `reconcile()` also waits in a loop on the pass
ahead of it and lets that pass's failure belong to its own caller.

Deliberately not done: no recovery of words already lost (they are
gone from vault.json; added once more, they now stay), and no change
to where or how the words are stored.

Verification: `harper-lint.test.ts` gains a dialect-change case on a
real `LocalLinter` (red before: no words, no ignores after the switch);
`harper-settings.test.ts` covers the merge (order kept, never shrinks);
the new `harper-runtime.test.ts` fakes the store and a deferred
`loadHarper` to force the boot ordering (the vault landing mid-compile
is imported and a `zg` saves all three words; a session that forgot its
words never writes a shorter list and learns the difference; an outside
change is imported once and the echo of an own write never; a failed
load leaves a clean slate), the first two red before. Reproduced and
re-checked in the built app over CDP with isolated stores: a vault
knowing two words, a dialect switch through config.toml, then `zg` on a
third. Before: all three underlined after the switch, vault.json left
with `["Glorpish"]`, the two vault words underlined at the end. After:
only the unknown word underlined after the switch, vault.json holds all
three, nothing underlined.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…ading (#822)

From @saran-ncsu (#822): since #543, switching into Preview opens the
reading view at the line you were editing, but the way back never
followed. Read on through a long note, scroll to a later section, press
Cmd+4, and the editor came back at the line the cursor had when you
left (the top of the note, for one you had just opened), so you
scrolled the whole way down again to fix the sentence you had just
read. The reading view was never consulted: Preview hides the editor
with its caret in place, and the mode switch only showed it again.

Now every route out of Preview (Cmd+4 and Cmd+5, the Edit / Split
toggle in the pane header, the palette, `:view`, `:editmode` and
`:splitmode`; they all end in `applyPaneMode`) reads what the reading
view has on screen off the rendered blocks' source-line stamps, while
that view is still in the DOM. If the cursor's line is inside that
range nothing moves: the "peek at the render, come back to the cursor"
contract from #543 holds. Otherwise the editor opens with the first
block still in view at its top, 24 px in like an outline jump, and the
cursor on that line, one frame after the switch so CodeMirror has
measured the freshly shown scroller. Into Split the reading view
reflows to half its width and that block moves, so the landing holds
the split scroll sync and scrolls the reading view back to the same
block beside the editor's copy. Explicit jumps that leave Preview with
a target of their own (a comment jump, an "open at this line" request)
pass `landing: 'caller'` and keep it.

Also from the report: double-click a paragraph, heading, list item,
quote or table in Preview and the note opens in Edit with the cursor
on that block's source line, kept at the height the block had on
screen (clamped so it neither hugs the top edge nor drops off the
bottom); in Split the double-click brings the editor to the block and
the reading view stays put. Links, buttons and form controls, image,
PDF and Excalidraw embeds, diagrams (whose double-click resets pan and
zoom) and transcluded `![[notes]]` (their stamps count the expanded
markdown's lines, not this file's) keep their own double-click. The
image embed's "Edit this block" button, which switched to Edit and
left the cursor wherever it was, now opens on the image's line too;
the figure inherits the stamp of the paragraph it replaces.

One more change was needed for the landing to hold. With the above in
place the Split route still landed on line 1 about two runs in three.
CodeMirror (6.43.8) mirrors a new selection into the DOM only while
the editor has focus; dispatched into the editor Preview had just
hidden, unfocused, the state moved but the DOM caret stayed where the
last click left it, and the DOM observer's next flush, some 3 ms
later, read that stale caret back as a user selection and snapped the
cursor to the old line, a frame before the deferred focus arrived.
`landEditorOnLine` now focuses the view before it dispatches.

Deliberately not done: Cmd/Ctrl-click to edit (the reading view
already gives that chord a meaning, creating a linked note, and a
modifier click on plain prose is not discoverable; the double-click is
what the VS Code markdown preview does), single-click to edit (it
would take selecting and copying text away from the reading view), any
change to how Preview lands when you enter it, and a landing for
renders without source-line stamps (nothing on screen with a line
means the cursor stays put, as before).

Verification: `preview-outline-jump.test.ts` gains cases for
`previewVisibleSourceLines` (a partly visible top block counts, one
whose bottom edge merely touches the top does not, open end at the end
of the note, unstamped blocks skipped, half-open range),
`previewEditRequestForTarget` (inline content resolves to its block;
links, controls, embeds, diagrams and transcluded notes are inert) and
`editorLandingTopMargin` (height kept, both clamps, the fallback); the
new `local-assets-image-edit-block.test.ts` checks the stamp moves
onto the figure and reaches the button's request. Re-checked in the
built app over CDP with isolated stores, five scenarios on a
20-section note: scrolled (before: back at line 1; after: the Section
20 heading at the top, cursor on line 157), peek (cursor unchanged),
double-click (Edit, cursor on line 97, editor focused), split (9 of 9
after the focus change, about 1 in 3 before) and split double-click
(cursor within 5 px of the paragraph's height, reading view unmoved).
The in-app manual (help.ts) describes the new behaviour on the modes
card and in the Mod+4/5/6 entry.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…ive Preview off (#827)

From @vinivinoo (#827), with three screenshots: a note that opens with
`---`, `type: topic`, `parent: [[Informatik]]`, `created: 2026-09-20`,
`---` shows the three property lines big and bold in Edit mode with
Live Preview off; adding a `tags:` line leaves them big; adding
`  - todo` under it makes the block normal again.

CommonMark is the cause. The editor parsed the whole note as markdown,
and markdown knows nothing about frontmatter: a paragraph of
`key: value` lines followed by a line of `---` is a setext H2, so the
block wore heading typography end to end, and the two fences read as
horizontal rules. The `- todo` line looked like a cure because a list
item interrupts the paragraph, which turns the closing `---` into a
plain rule; that is the third screenshot. The properties card (#616,
Live Preview on only) carried CSS that suppressed heading typography
inside the card, so the bug hid there and showed with Live Preview
off, where the card does not load and the raw text was unprotected.

The fix is at the parser, not in CSS. Every ZenNotes editor now parses
with a small note grammar (`cm-markdown-language.ts`): a Lezer parser
that re-scans the frontmatter range from the raw input on every parse,
emits `Document > Frontmatter(FrontmatterMark, FrontmatterMark) Body`,
and mounts the markdown parser onto `Body` through `parseMixed`, so
the body tree is the same markdown tree every existing extension
walks and no heading, list or rule token can come out of the
frontmatter in any mode. The block's range is the one the card
computes: `isFrontmatterFence` (shared-domain `markdown-lines.ts`) is
the single predicate behind `frontmatterEndIndex`, `frontmatterRange`
and the grammar. The mount hands the previous markdown tree to the
inner parser as fragments, so edits in the body or inside the
frontmatter reuse the untouched blocks; the two moments the body start
moves (the closing fence typed for the first time, or broken) cost one
full body parse, the same as opening the note, because parseMixed
cannot find the old mount across that move (`StructureCursor.moveTo`
in @lezer/common drops `IgnoreMounts`, same on upstream main). The
frontmatter content is left untokenized on purpose: with Live Preview
off a note reads as its raw text (#616), and the card styles it when
on. Only the fences carry a token (`meta`).

Two ergonomics markdown used to provide by accident are kept on
purpose: Enter at the end of `  - todo` inside the frontmatter still
inserts the next `  - ` at the same indentation, and Enter on an empty
`- ` clears it. `insertNewlineContinueFrontmatterList`
(cm-frontmatter.ts) does that for lines strictly between the fences
and returns false for everything else; the vim-aware keymap registers
it ahead of markdown's Enter, deferred to Vim in normal mode like the
rest. `Document` carries `indentNodeProp` `() => null` like the
markdown Document, otherwise the default Enter would drop the
indentation of a nested frontmatter line.

`noteMarkdown()` replaces the `markdown({ base, codeLanguages,
addKeymap: false })` setup in EditorPane, ExternalFileApp,
FloatingNoteApp, QuickCaptureApp and PinnedReferencePane.
cm-wysiwyg-blocks drops its frontmatter HR skip (the fences are no
longer HorizontalRule nodes); index.css drops the setext suppression
and the token-reset list inside the card, keeping only the rule that
lets the fence mark inherit the card's transparent fence rows.

On screen: with Live Preview off the property lines are body text and
the two fences read as plain `---` in body color instead of accent
rules; with Live Preview on the card looks as before and
`[[Informatik]]` still renders as a wikilink chip.

Deliberately not done: `yamlFrontmatter` from @codemirror/lang-yaml
(an unclosed `---` turns the entire note into YAML, and it disagrees
with the card about a fence with stray whitespace); a markdown
block-parser extension (Lezer reuses the old rule node when the
closing fence is typed lines below an existing `---`, so the stale
heading stays until the note is reopened); YAML syntax coloring in the
raw view (#616); the template editor, which keeps its own
`yamlFrontmatter` setup where the block is the point; and inline
markdown inside the card (`**bold**` in a frontmatter value used to
render bold because the value was parsed as markdown; YAML values are
strings, and now they read as such).

Verification: `cm-markdown-language.test.ts` (13: the reported note
parses without a heading, agrees with `frontmatterRange`, an unclosed
fence stays a rule, only line 1 starts a block, longer dash runs and
fences with text are not fences, empty and frontmatter-only documents,
a closing fence straddling the 4096 read boundary, language activity
per region, nested code fence languages, fragment reuse for body and
frontmatter edits, the block recognised the moment the closing fence
is typed and dropped the moment it is broken) and
`cm-frontmatter-enter.test.ts` (10, a real EditorView dispatch through
the full keymap: list continuation, empty item cleared, nested key
copies indentation, top-level key plain newline, closing fence left to
the default Enter, cursor before the marker, a body list still
continues, an unclosed block is not frontmatter, Vim normal mode Enter
is a motion, Vim insert mode continues). Re-checked in the built app
over CDP with isolated stores (`check-827-frontmatter-heading.mjs`,
release tools): before, with Live Preview off the three property lines
carried `tok-heading2` and stood taller than body lines, and with it
on the token was still there under the card's CSS; after, no heading
token in either mode, the lines at body height, the body's `# InfSec`
still an H1 and its real setext heading still an H2, the five-line
card with exactly one rule widget and the wikilink chip when on.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
)

From @vinivinoo (#825): in visual mode the Vim motions Ctrl+D and
Ctrl+U do nothing useful. Reproduced in the built app on a 200-line
note. Where Mod is Ctrl (Linux, Windows) `v` then Ctrl+D added a new
cursor on every press instead of growing the selection, and Ctrl+U
undid the selection; nothing scrolled. On macOS the keys fell to
codemirror-vim's stock `moveByScroll`, which happens to work on a
plain note but is the motion ZenNotes replaced in normal mode because
its pixel math can snap the cursor back to line 1 under live-preview
decorations and folded headings.

Two things were wrong. The half-page keys were mapped as Vim actions
in normal mode only (`zenHalfPageDown` / `zenHalfPageUp`), and an
action cannot extend a visual selection at all: it moves the CodeMirror
selection but leaves Vim's own `vim.sel` alone. With the visual context
unmapped, the key never reached Vim there. CodeMirror's keymap handler
runs before the Vim plugin sees a key (the `keymap` facet registers its
handler where the first `keymap.of` sits in the extension list, and the
snippet keymap precedes `vim()`), so the search keymap's Mod-d (select
next occurrence) and the history keymap's Mod-u (undo selection) took
the chords. `vimHalfPageKeymap`, which exists to hand these chords to
Vim ahead of those keymaps, bailed out in visual mode on purpose, since
the action would not have done anything useful there.

The half-page move is now a Vim motion, `zenMoveByHalfPage`
(`cm-vim-half-page-motion.ts`). Vim moves the head itself, so `v` plus
Ctrl+D grows the selection exactly as far as Ctrl+D moves the cursor in
normal mode, `V` grows it by whole lines, Ctrl+U shrinks it back, and
Escape leaves the cursor where the selection ended. The cursor walks
display lines through `moveVertically` and stops at the first or last
line instead of wrapping; the viewport scrolls by a fixed half of the
visible editor, clamped to the note, which is what the old action did
in normal mode. The goal column survives consecutive presses through a
short line (`vim.lastHSPos`, reused while the last motion was this
one). A count is Vim's: `N<C-d>` moves N lines and scrolls N line
heights (`explicitRepeat`, so a bare press still reads as half a
page). The pixel path runs inside `pixelMotionFallback`, the #574
safety net now exported from `cm-vim-display-line.ts`: if a coordinate
query throws, the cursor still moves by logical lines and the viewport
still scrolls. The main editor's keymap sync maps the user's configured
`nav.halfPageDown` / `nav.halfPageUp` bindings to the motion in normal
and visual context (`VimKeymapMapping` gains a `{ motion, motionArgs }`
shape next to `{ action }`), and `vimHalfPageKeymap` now defers only in
insert mode, where Vim's own Ctrl+D (unindent) keeps its place.

The floating note, Quick Note and external-file windows build their
own editor and never had the half-page keys mapped, so they had the
stock motion on macOS and, where Mod is Ctrl, lost the chords to the
search and history keymaps in normal mode as well: Ctrl+D selected the
word under the cursor, visual Ctrl+D stacked cursors. Each now lists
`vimHalfPageKeymap(prefs.vimMode, {})` first in its keymap and calls
`registerHalfPageMotion()` plus `mapDefaultHalfPageKeys()`, which maps
the default Ctrl+D / Ctrl+U to the motion in both contexts. Those
windows consult no keymap overrides for any other binding either, so a
remapped half-page key still applies to the main editor only.

Deliberately not done: operator-pending `d<C-d>` stays on Vim's default
motion, like j/k; insert-mode Ctrl+D is untouched; and the in-app
manual already says Vim keeps Ctrl+D / Ctrl+U for half-page scrolling,
which is now true in every mode, so its wording stands.

Verification: `cm-vim-half-page-motion.test.ts` (12, on a fake view
with known geometry: half-page distance in lines and pixels, the typed
count, at least one line and no division by a zero line height,
forward and back with the scroll clamped at both ends, no wrap at the
first or last line, a count that moves and scrolls N lines, a leftover
repeat ignored, the goal column kept through a short line, the logical
fallback when the pixel path throws, one line per step without a
view); `vim-half-page-keymap.test.ts` now mounts with a keymap before
`vim()` as EditorPane does and gains the visual-mode case, red against
the old visual-mode bail (two ranges) and red for three of five cases
when the keymap is left out of the mount;
`cm-vim-half-page-default-keys.test.ts` (4, jsdom, the secondary
windows' wiring with the real motion: normal Ctrl+D moves instead of
selecting the word, a visual selection grows as one range and shrinks
back, a count moves N lines, insert mode is left alone; three of four
red when either the mapping or the keymap entry is removed).
`npx turbo run typecheck --force` 7 of 7; app-core 2603 passed and 1
skipped. Re-checked in the built app over CDP with isolated stores
(`check-825-visual-half-page.mjs`, `check-825-floating.mjs`, release
tools), each in a macOS run and a run with `navigator.platform`
overridden to Linux: main editor 13 of 13 (normal Ctrl+D lands on line
11 with the scroll to match, `v` plus Ctrl+D selects lines 1 to 11 as
one range with the same scroll, a second press reaches 21, Ctrl+U back
to 11, `V` selects whole lines, a selection started mid-window stays on
screen, insert-mode Ctrl+D does not page); floating note and Quick
Note windows all green (1 to 13 to 25 in the floating window, 1 to 8
to 15 in the smaller Quick Note window, one range throughout, cursor
count unchanged). Before the fix the Linux runs showed three stacked
cursors and no scroll in both the main editor and the floating window.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
From @diazkev314 (#831): with vault A open in the desktop app,
`zn mcp --vault <vault-B>` served vault A. `vault_info` named the
app's workspace, and `--vault /tmp` still answered with the real
vault. The same path left `--server` and `--token` unused, even
though both are advertised as global flags and the MCP
authentication error itself suggests `--token`. Reproduced against
the built `cli.js` over stdio with isolated config and user-data
directories: every flag scenario answered vault A, and a fake server
named with `--server` was never contacted.

The CLI dispatcher parsed the flags and then called `cmdMcp()`
without them, and `runMcpServer()` had exactly one way to find a
vault: `resolveDefaultTarget()`, the environment first and then the
workspace the desktop app has open (#688). That is the right answer
for the legacy `out/main/mcp.js` entry, which has no flags at all,
and it was the only answer `zn mcp` could give. `ZENNOTES_VAULT` and
`ZENNOTES_SERVER` worked because they sit inside that default path,
which is how the flags' failure stayed hidden when both were set.

`runMcpServer` now takes a `resolveTarget` function and `cmdMcp`
hands it the parsed arguments through the same `resolveTarget()`
every other command uses, so `--vault <name or path>`, `--server`
and `--token` mean the same thing in `zn mcp` as in `zn list`.
Without flags the server follows the environment and then the app,
as before, and the legacy entry is unchanged. The vault resolves
once at startup and stays pinned for the session; a failure is
retried on every tool call rather than repeated.

A `--vault` that names nothing does not kill the server. The CLI's
usual "resolve, then fail the command" would leave an MCP client
with a dead process and no message, so `zn mcp` resolves at
startup, prints the error on stderr (`[zennotes-mcp] No vault named
"...". Known vaults: ... The MCP server is running anyway; every
tool call returns this error until a vault resolves.`) and keeps
serving; each tool call returns the same error text so the
assistant can relay it, and the next call tries again. stdout is
the protocol channel and stays clean; a healthy start prints
nothing. The in-app manual's MCP card now tells people to pass
`zn mcp --vault work` or `zn mcp --server home --token <token>` in
the client config, next to the existing environment variables.

Deliberately not done: the legacy stdio entry gains no flags (its
clients launch it with none); the flag shape is not mirrored in the
Go `zn`, which already passed its flags into the MCP server.

Verification: `server.test.ts` gains five cases that run the real
server over an in-memory transport pair against a scratch config
directory with two known vaults: no flags follows the active vault
with an empty stderr; `--vault` by path and by known name serves the
other vault, `list_notes` included; a bad `--vault` writes exactly
one stderr line naming the known vaults and `vault_info` still
answers with the error; `--server 127.0.0.1:<port> --token
secret-831` reaches a fake HTTP server with `Bearer secret-831` on
every request and reports `kind: remote`; a resolver that fails
twice and then succeeds is called three times, the first success
stays pinned. Four of the five were red with the resolver ignored.
`npx turbo run typecheck --force` 7 of 7; desktop 891 passed and 4
skipped. Re-checked with the built `cli.js` over stdio
(`check-831-mcp-flags.mjs`, release tools): before the fix 5 of 7
checks failed; after, 11 of 11 pass, including the stderr warning
arriving before the first request and the legacy `mcp.js` entry
still following the active vault.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
From @uNyanda (#828), Vim mode with the insert escape set to `jk`
(Settings, Editor, Vim, "Exit insert mode with"): leaving insert
mode with `jk` without changing any text updated the note's
`{{modified_time}}`, `{{modified_date}}` and `{{modified_datetime}}`
tokens, while a plain Escape did not. Reproduced in the built app on
a note carrying `Updated: {{modified_time:HH:mm:ss}}`: `jk` moved the
file's mtime and the rendered stamp, and so did typing a character
and deleting it again before Escape.

The cause is in how codemirror-vim (6.3.0) handles a two-key insert
mapping. On the `j` it cannot yet know whether `k` follows, so the
`j` IS typed into the document; when `k` arrives inside the 200 ms
window the `j` is removed again. Two document changes, identical
final text. The editor's update listener calls `updateNoteBody` on
each, and the store marked the note dirty on the insert and never
un-marked it on the removal, so the 350 ms debounced `persistNote`
wrote the same bytes back to disk. The mtime moved with the write,
and the tokens render from the note's `updatedAt`, which follows
mtime. Nothing in that chain is vim-specific: type-a-char-then-
backspace and undo-back-to-the-saved-text took the same path.

The store now remembers what is on disk for every dirty note. The
moment a clean buffer first drifts from disk, `updateNoteBody`
snapshots the previous body (a clean buffer equals disk); each
completed `persistNote` moves the snapshot to the bytes it wrote.
An edit that brings the buffer back to the snapshot is not a change:
the note is marked clean, the pending debounced write is cancelled,
and no write happens. Real typing that ends in `jk` still saves once,
because the buffer does not return to the saved bytes.

Two choices in the comparison. It is skipped while a write is in
flight (`pathSaveQueues` holds the path), because the bytes on disk
are changing at that moment; a revert then just saves normally, so
an edit is never lost to the shortcut, and the completion records
what actually landed for the next comparison. And a buffer that
returns to the saved bytes is clean even if another program has
changed the file underneath in the meantime: the app keeps showing
its own text and no longer clobbers the external change with a
pending save, where before it did. A visible side effect is that the
"Unsaved changes" dot clears on a revert, which is what VS Code does.

Deliberately not done: skipping editor updates while a Vim key
buffer is pending (a lone `j` followed by the timeout would lose the
character if the app quit in between), and comparing bytes in the
main process before writing (an extra read on every save, desktop
only, and the web client shares this store). The watcher, vault
resets and `withoutNoteInWorkspace` are unchanged; a snapshot left
behind for a clean note is replaced on that note's next edit and is
never read while the note is clean. The in-app manual's wording,
that the tokens show when the file was last saved, stands.

Verification: `store-note-integrity.test.ts` gains five cases under
"#828: a buffer back on its saved bytes is not rewritten": an
inserted character removed again cancels the save (no write, dirty
false, `activeDirty` false); real typing ending in the escape
sequence saves once; a revert is measured against the last save and
not the body the note opened with; a revert while a write is in
flight still lands on disk; typing ahead of a write and returning to
the written body is clean. Three of the five were red on the old
store. `npx turbo run typecheck --force` 7 of 7; `test:run` green on
every package (shared-domain 1685, app-core 2608 passed and 1
skipped, desktop 891 passed and 4 skipped). Re-checked over CDP in
the built app with both stores isolated and `insert_escape = "jk"`
(`check-828-escape-mtime.mjs`, release tools): before the fix 4 of
12 checks failed, `jk` moved the mtime and the stamp from 09:19:37
to 09:19:40 and the dirty dot lit up; after, 12 of 12 pass, mtime
and stamp unchanged through `jk` and through type-then-backspace,
the dot stays off, and a real edit still reaches disk.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…826)

From @vinivinoo (#826): in note search (Space f, Mod+P), a name that
matches no note ended in "No matches." and a trip through New Note
to type the same name again. Obsidian's quick switcher offers to
create the note right there with Shift+Enter; the request was the
same move in ZenNotes.

The palette now reads the free text of the query as the name of a
note to create. When no live note carries that exact name, a Create
row follows the results ("Create "Meeting notes"" with a badge that
names the destination, INBOX or INBOX/projects), the arrow keys
reach it, Enter on it creates, a click creates, and Shift+Enter from
anywhere in the palette creates without moving the highlight. The
new note opens in the editor with the focus there, the same landing
as `:e name` and the dead-wikilink flow, because its name is already
settled. Tags in the query keep filtering the results and are never
written into the new note.

Where the note lands follows `:e`: a bare name goes to the Inbox
root, `projects/roadmap` nests under Inbox, a leading top folder
(`archive/old`, `quick/x`) picks that folder, and `/name` forces the
Inbox root. The parsing is `parseCreateNotePath`, so the same names
are rejected as everywhere else (`# ^ [ ] \ : * ? " < > |`, `.` and
`..`), and a path into the Trash is never offered.

An exact match hides the row. A live note at the same path, or, for
a bare name, any live note with the same title (case-insensitive),
counts as "already there": the row disappears and Shift+Enter opens
that note instead, so a Shift+Enter typed a moment too late cannot
mint a same-named twin. Fuzzy and partial matches do not count
("Road" still offers to create "Road" next to Roadmap), and a
trashed note never counts. `/Name` is the escape hatch for a real
duplicate in the Inbox root.

The footer gained `Shift+↵ create`. To keep it on one line at the
palette's width, the `Ctrl+J/K` badge is gone (the sibling palettes
list only `↑↓ Ctrl+N/P move`; the keys still work through the shared
palette navigation and the in-app manual still documents them) and
"move to trash" reads "trash".

Deliberately not done: tags from the query are not added to the new
note (a `#tag` in the search is a filter, and a note created from a
search with a tag that then gains that tag would be a surprise both
ways); no other palette gained a create row; and the destination is
not the "New notes go to" setting, since `:e` and the dead-wikilink
flow already read a bare name as Inbox and the row names the target
before anything is written.

Verification: `npx turbo run typecheck` 7 of 7. New unit tests:
`search-create.test.ts` (6: bare name to Inbox root, slash paths and
top folders, nothing for empty, unfileable or Trash names, exact
match found case-insensitively, fuzzy and partial matches not
counted, trashed notes never counted) and a describe in
`SearchPalette.test.ts` (7, jsdom: the row for an unknown name and
Shift+Enter creating in Inbox, the row after fuzzy matches with the
arrows reaching it and Enter creating, plain Enter on a match never
creating, a typed path nesting the note with the badge naming it,
the row hidden for an exact name with Shift+Enter opening that note,
nothing for an empty or tag-only query or an unfileable name, tags
filtering but never naming the note); 16 of 16. The full app-core
suite: 2621 passed and 1 skipped. Live in the built desktop app over
CDP with both stores isolated and Vim mode on (three runs, 23 of 23
checks each): Mod+P and `Space f` open the palette; "Meeting notes"
shows the row and Shift+Enter writes `inbox/Meeting notes.md` with
`# Meeting notes` and the editor focused; "projects/Q4 plan" shows
INBOX/projects and writes `inbox/projects/Q4 plan.md`; "roadmap"
with `projects/Roadmap.md` present hides the row and Shift+Enter
opens Roadmap; a tag-only query and Escape create nothing. Demo clip
recorded from the same build for the release notes.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…and tags (#826 follow-up)

The first cut of #826 created the note in one keystroke: Shift+Enter
in note search wrote `inbox/<query>.md` and opened it. Trying it
raised three gaps. There was no say over where the note lands short
of typing a path into the search box; a name that already existed
elsewhere in the vault was created again with nothing said; and tags
had to be typed into the body afterwards, even when the search
already carried `#tag` words.

The Create row now stands for any free text in the query and reads
`Create "Meeting notes"…`, the ellipsis saying a step follows. Enter
or a click on it, or Shift+Enter from anywhere in the palette, opens
a New note form inside the same palette modal, with the query kept
so Escape returns to the results and the search input. The form has
three fields. Name is prefilled from the query and selected, so
retyping replaces it and Enter creates right away: the fast path
costs one Enter more than before, in exchange for seeing the target
before anything is written. Folder speaks the `:e` dialect (empty is
the Inbox root, `projects/x` nests under Inbox, `archive/x` and
`quick/x` pick that area) and, once focused, lists every folder of
the vault below the fields, roots first: Inbox, Quick Notes, Archive
and their subfolders, never the Trash and never a database folder.
Typing narrows the list and preselects the first match, Enter takes
it and moves on to Tags; ArrowUp above the first row keeps the typed
value, which is how a folder that does not exist yet gets created.
Tags takes chips: the query's `#words` arrive as chips, the vault's
tags are listed most used first, typing narrows them with a "new
tag" row last, Enter picks, Space or comma commits the typed text,
Backspace on an empty field removes the last chip, and text that
cannot be a tag blocks Create with the reason.

Before writing, the form checks the name against the live notes. A
same-titled note in the chosen folder blocks Create and says
`"Weekly review" already exists in Inbox. Change the name, or open
it.`, with Shift+Enter (or the Open it button) opening that note
instead. A same-titled note in another folder only warns, since the
folder was chosen on purpose. Comparison is by title, ignoring case,
and never counts a trashed note or a partial match. The status line
under the fields always names the outcome in words: `Creates "Q4
plan" in Inbox › projects with #planning #q4`. Ctrl+Enter or
Cmd+Enter creates from any field.

Tags are written as one `#a #b` line under the heading, the way
`zn create --tag` and `zn capture --tag` write them, not as
frontmatter; a vault with no frontmatter habit should not gain one
from this form.
`createAndOpen` takes the tags and writes the body after the vault
has settled the title, so a name the vault adjusted still heads the
note. `countVaultTags` and `rankTagCompletions` moved to `lib/tags`
so the form and the two editor completions share one ranking.

Deliberately not done: no frontmatter; no folder creation UI beyond
typing the path; no create rows in the other palettes; and the
default destination stays the Inbox root rather than the "New notes
go to" setting, matching `:e` and the dead-wikilink flow.

Verification: `npx turbo run typecheck` 7 of 7; `npm run test:run`
all green (app-core 2644 passed, desktop 891). New unit tests:
`search-create.test.ts` (21: draft splitting in the `:e` dialect,
destination parsing and the Trash and database refusals, name
checks, collisions by folder and by title, the folder picker's order
and filtering, tag normalization and ranking, the body the CLI would
write), `SearchPalette.test.ts` (13 in the form describe, jsdom:
Shift+Enter opens the form with the name selected and Enter creates
in Inbox; the row follows fuzzy matches and Enter on it opens the
form; plain Enter on a match never does; a typed path fills Folder
and the picker filters, picks and moves to Tags; ArrowUp keeps a
typed folder that does not exist; a same-folder name blocks Create
and Shift+Enter opens the note; a name elsewhere only warns; query
`#words` become chips and the picker offers vault tags; leftover Tags
text counts on Create and a non-tag blocks; Ctrl+Enter and Cmd+Enter
create from any field; an unfileable name opens the form with the
reason and Escape goes back with the query kept; a Trash path is
refused; nothing for an empty or tag-only query), and `store.test.ts`
(2: the tag line under the settled title, and the body untouched
without tags). Live in the built desktop app over CDP with both
stores isolated and Vim on, 44 of 44 checks: the form opens from
Shift+Enter, Enter on the row and a click; Enter in Name writes
`inbox/Meeting notes.md`; Tab into Folder lists every folder, "proj"
narrows to projects, Enter picks it and lands in Tags, "pl" + Enter
takes planning, "q4" + comma adds a new one, Enter writes
`inbox/projects/Q4 plan.md` with `#planning #q4` under the heading;
"Weekly review" blocks Create and Shift+Enter opens the existing
note with no twin on disk; Escape returns to the results with the
query kept. Demo clip recorded from the same build for the release
notes.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
The release PR's macOS run failed one new #828 test, "typing ahead
of a write and then returning to the written body is clean", with a
second write the test never made: `INDEX_BODY with unsaved edits`.
That body belongs to an older test in the same file, "still refuses
to let an add event overwrite unsaved edits". It edits the open note,
which arms the store's real 350 ms autosave timer, checks the buffer,
and returns with the timer still running. The timer fires into
whichever test is on the clock 350 ms later and writes through that
test's `window.zen`, so the stray write lands in its `writeCalls`.

Nothing sat after that test before this cycle, so the leak always
fired after the file was done and stayed invisible. The #828 group is
the first downstream of it that counts writes, and on the slow macOS
runner the timer landed inside its last test; the other platforms and
a local run were fast enough to fall through a harmless gap. A scratch
copy of the file with a 400 ms pause in the #828 setup reproduces the
failure on every run and passes with this change.

The watcher test now settles its own save through `persistNote` and
asserts the edit reached disk, so no timer outlives it. The #828 test
also installs fake timers before its first edit instead of halfway
through: it used to arm one real timer and then hand the real id to
the fake `clearTimeout`, which cannot cancel it. Test-only change; the
store is untouched.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
@adibhanna
adibhanna merged commit 173c61f into main Sep 22, 2026
9 checks passed
@adibhanna
adibhanna deleted the v2.54.0 branch September 22, 2026 17:33
@adibhanna
adibhanna restored the v2.54.0 branch September 22, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant