Skip to content

editor: LaTeX command completion in math regions - #594

Open
flokchvtr wants to merge 1 commit into
ZenNotes:mainfrom
flokchvtr:math-completions
Open

editor: LaTeX command completion in math regions#594
flokchvtr wants to merge 1 commit into
ZenNotes:mainfrom
flokchvtr:math-completions

Conversation

@flokchvtr

Copy link
Copy Markdown
Contributor

What

LaTeX command completion inside math regions. Typing \su in $…$, $$…$$, or a ```math fence pops KaTeX commands with the rendered symbol in the completion row's icon slot — same row layout as the slash commands.

  • ~250 curated commands: greek, big operators, accents, fonts (\mathbb, \mathcal, …), relations, arrows, named functions, delimiters, and matrix/cases/aligned environments.
  • Argument-taking commands insert as snippets: accepting \frac lands the cursor in the numerator, Tab moves to the denominator. Big operators scaffold their usual bounds — \sum inserts \sum_{i=1}^{n} with each bound selected in turn, so the defaults are one Tab away from being kept or one keystroke from being replaced.
  • Environments unfold on their own lines (\pmatrix\begin{pmatrix}…\end{pmatrix}).

How

One more completion source in the editor's existing autocompletion stack, plus a _kind === 'latex' branch in the shared renderCompletion (the pattern callouts and wikilinks already use). No new dependencies — katex and @codemirror/autocomplete are already in the tree.

Math-region detection counts unmatched $/$$ delimiters rather than closed pairs: a formula still being typed (no closing $ yet) already counts as math, which is exactly when completion matters. Escaped dollars (\$5) and code regions are excluded; a fence whose info string is math is treated as a math region in its own right, matching how remark-math renders it.

Tests

cm-latex-completions.test.ts covers the context detection (inline open/closed, block open/closed, ```math vs other fences, escaped dollars, inline code) and the token matcher (\su, bare ``, `\` row-break rejection). `npm run typecheck` and the app-core suite pass.

Out of scope

Completion only activates in math regions, so \ stays inert in prose. The command table is a curated constant — happy to trim or extend it to taste.

Typing `\su` inside `$…$`, `$$…$$`, or a ```math fence pops KaTeX
commands with the rendered symbol in the completion row's icon slot.
~250 curated commands (greek, operators, accents, fonts, relations,
arrows, functions, delimiters, environments); argument-taking commands
insert as snippets — accepting `\frac` lands the cursor in the
numerator and Tab moves to the denominator — and big operators
scaffold their usual bounds (`\sum` → `\sum_{i=1}^{n}` with each bound
selectable in turn).

Math detection counts unmatched `$`/`$$` delimiters rather than closed
pairs, so a formula still being typed — the moment completion matters —
already counts as math. Escaped dollars and non-math code regions are
excluded; a fence with the `math` info string is a math region in its
own right, matching how remark-math renders it.

One more source in the editor's existing autocompletion stack; no new
dependencies (katex and @codemirror/autocomplete are already there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flokchvtr
flokchvtr requested a review from adibhanna as a code owner August 14, 2026 13:48
adibhanna added a commit that referenced this pull request Aug 14, 2026
…kchvtr)

Typing `\su` inside `$…$`, `$$…$$`, or a ```math fence now completes KaTeX
commands with the rendered symbol in the row, and argument-taking commands
land as snippets: accepting `\frac` puts the cursor in the numerator, `\sum`
scaffolds its bounds a Tab apart.

Merged as sent. Review findings follow in their own commit.
adibhanna added a commit that referenced this pull request Aug 14, 2026
…s in context

Review follow-up to #594, kept out of the contributor's commit.

A note set to the Typst typesetter takes different syntax entirely, so
offering `\frac{}{}` in one was wrong every time. The completion source now
reads the renderer the pane is configured with and stays silent unless it is
KaTeX. The facet already carried the answer; it just had no accessor.

Dollars inside code were being counted as delimiters. A note with `echo $$`
in a shell block flipped the parity for everything below it, so `\` popped
LaTeX commands in plain prose from there on, and a `$5` in inline code did
the same for the rest of its line. Both counts now skip anything the syntax
tree says is code, which is the same question the cursor position was
already being asked.

The block scan also ran from the start of the document on every `\` typed,
allocating the whole prefix as a string. It now looks back a bounded window:
a display block opened further up than that is not a formula anyone is still
typing.

The rendered previews are cached between popups. A bare `\` opens the entire
table at once, and typesetting every visible row through KaTeX on each open
was the one cost this feature could be felt through.
@adibhanna

Copy link
Copy Markdown
Contributor

Merged into the v2.28.2 release branch (8cd147d) and shipping in 2.28.2. Thank you, this is a genuinely nice piece of work: the rendered symbol in the icon slot is the part that makes it feel finished, and the unmatched-delimiter reasoning is exactly right, since the closing $ is precisely what does not exist yet while you are typing.

I drove it in the app before merging: inside $$…$$, typing \su opens 13 options with \sum boosted first, Enter lands \sum_{i=1}^{n} with the bounds a Tab apart, and the same keystrokes in prose stay inert.

Three review findings landed in a follow-up commit (5eb6d5c) rather than in yours, which is how this repo keeps contributor work intact:

  1. Typst notes. ZenNotes has a math_renderer setting, and a note set to Typst takes different syntax, so \frac{}{} would be wrong there every time. The source now reads the renderer facet the pane is configured with and stays silent unless it is KaTeX.
  2. Dollars inside code counted as delimiters. A note containing echo $$ in a shell block (the shell's PID) flipped the parity for everything below it, so \ popped LaTeX commands in plain prose from that point on; a $5 in inline code did the same for the rest of its line. Both counts now skip whatever the syntax tree says is code, which is the same question your codeContext was already asking at the cursor.
  3. Two cost trims. The block scan started from offset 0 on every \ typed, allocating the whole prefix as a string; it now looks back a bounded window. And the KaTeX previews are cached between popups, since a bare \ opens the whole table and every visible row was being typeset again on each open.

None of that changes what you built or how it feels. Tests came along for each: code-fence and inline-code dollars, and the Typst gate.

If you want to keep going in this area, the obvious next step is the same treatment for Typst syntax behind the same setting.

adibhanna added a commit that referenced this pull request Aug 14, 2026
* Fix(cloud): store the cloud credential on desktops Chromium does not recognize

On Linux, Chromium picks the safeStorage keyring backend from
XDG_CURRENT_DESKTOP instead of probing the bus for a Secret Service. On
compositors it does not recognize (Niri, Hyprland, Sway) it falls back to
the plaintext basic_text backend, safeStorage reports encryption as
unavailable, and cloud sign-in dies with "could not store the cloud
credential securely" even though gnome-keyring is running and healthy.
Reported on Discord by a Niri user whose secret-tool round trip worked,
which is the confusing part: nothing the user can check locally is
consulted by Chromium's detection.

The fix appends --password-store=gnome-libsecret before app ready, only
when the session variables identify no desktop Chromium recognizes and
the user did not pass the switch themselves. Safe by construction: when
the Secret Service is genuinely absent, Chromium falls back to
basic_text, the same outcome as today. On recognized desktops, including
XFCE and LXQt where Chromium chose plaintext deliberately, ZenNotes
defers to Chromium's own choice.

The storage failure dialog and the remote-workspace console warning now
point at the switch instead of leaving a dead end.

* Fix(editor): a note being edited can no longer be wiped by its own save echo (#585)

Typing a multi-line mermaid block (or editing bullet lists) could erase
the entire note, with undo unable to restore it. Four small defects had
to line up, and all four are fixed here.

writeNote was truncate-then-write, so for a moment every save leaves an
empty file on disk. The watcher's awaitWriteFinish delays the echo of
one save into exactly that moment of the next, and the echoed readNote
comes back empty. The store's live change path then pushed that read
over the open buffer without checking the dirty flag (the resync path
has the check, with a comment explaining why; the live path did not).
The editor applies external content as a non-undoable doc swap (#247),
which is why undo could not bring anything back. And persistNote cleared
the dirty flag even when keystrokes landed during the write, so the
follow-up save that would have healed the disk bailed on its dirty
check, making the wipe permanent. Mermaid and bullet lists are only
amplifiers: per-line save cycles plus renderer stalls from the first
mermaid render push the echo into the truncate window.

Note saves now go through writeFileAtomic so no reader can observe a
half-written note; the watcher ignores the atomic-write scratch files
(which also stops every database save from firing an asset refresh); a
dirty buffer is never replaced by a watcher read, the pending save
reconciles disk instead; and the dirty flag survives a save whenever
the buffer has moved past what hit disk.

Verified by driving the built app over CDP: forcing the empty read
mid-edit wiped the editor and disk with undo dead before the change,
and leaves buffer, disk, and undo history intact after it. Regression
tests cover both store defects in store-note-integrity.test.ts. The Go
server's WriteNote shares the truncate-then-write shape; the renderer
guard already protects web buffers, and the Go write gets its own
follow-up once its watcher's rename semantics are verified.

* editor: LaTeX command completion in math regions

Typing `\su` inside `$…$`, `$$…$$`, or a ```math fence pops KaTeX
commands with the rendered symbol in the completion row's icon slot.
~250 curated commands (greek, operators, accents, fonts, relations,
arrows, functions, delimiters, environments); argument-taking commands
insert as snippets — accepting `\frac` lands the cursor in the
numerator and Tab moves to the denominator — and big operators
scaffold their usual bounds (`\sum` → `\sum_{i=1}^{n}` with each bound
selectable in turn).

Math detection counts unmatched `$`/`$$` delimiters rather than closed
pairs, so a formula still being typed — the moment completion matters —
already counts as math. Escaped dollars and non-math code regions are
excluded; a fence with the `math` info string is a math region in its
own right, matching how remark-math renders it.

One more source in the editor's existing autocompletion stack; no new
dependencies (katex and @codemirror/autocomplete are already there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): frontmatter tag autocomplete and clickable tags

* Fix(vault): saving a note is atomic everywhere, and never replaces a symlink (#585)

The desktop stopped wiping notes in 376245e by writing them atomically, but
the self-hosted server still wrote truncate-then-write, so a browser or a
second device could read a note in the moment its own save had emptied it. A
Go test that reads the file while WriteNote runs proves it: against the old
implementation a reader sees 0 bytes, the same empty read that erased notes
on the desktop. The server writes atomically now.

Verifying that turned up three things a rename does that a plain write did
not, two of them regressions the desktop change had already shipped.

A rename replaces the directory entry, not the file. Pointed at a symlinked
note it leaves a regular file where the link was, so a note kept in one place
and linked into the vault silently becomes two files. The same call writes
config.toml, so a stow or chezmoi managed ~/.config/zennotes/config.toml was
being replaced by a regular file on startup with the dotfiles copy never
seeing another change, and that one predates this cycle. Both writers now
resolve the link and do the atomic dance at the target, which is what
workflow-apply.ts had already worked out for workflow edits. A rename also
carries the temp file's permissions, so a note chmod'ed to 0600 came back at
the default: an existing file's mode is now reproduced exactly, while files
these calls create are left umasked exactly as they were.

The event kinds changed too, and clients read them. Linux maps a rename into
place to IN_MOVED_TO, which fsnotify folds into Create, so an atomic save
reaches clients as "add" rather than "change", and the renderer ignored "add"
and would have gone on showing content that no longer existed on disk. It now
treats "add" for a note it holds open as content to read, which also repairs
edits made by git, rsync, Syncthing or vim, none of which ever refreshed an
open tab. macOS reports the same rename as a delete followed by a create, and
a client told its open note was deleted closes the tab, so the watcher stats
the path and never calls an existing file unlinked. Both watchers drop the
scratch file itself, which otherwise made every keystroke-driven save re-list
the whole asset tree.

Verified past the unit tests: the real server binary driven over its change
feed the way a web client subscribes (no scratch events, symlink intact and
written through, 0600 kept), the full Go suite and an inotify probe inside a
Linux container, and the #585 reproductions replayed against the rebuilt
desktop app.

* Fix(prompt): pickers with a list are tappable one-handed on touch devices

A prompt autofocused its input as soon as it opened, which on a phone summons
the soft keyboard over the very list the user is about to tap. The folder
picker was the worst of it: the suggestions it exists to offer were hidden
behind the keyboard the moment it appeared.

On a coarse pointer, a prompt that actually has suggestions now opens
tap-first: no autofocus, no hint line about arrow keys and Ctrl+J that a
phone has no way to press, and taller rows to aim at. Typing is still one tap
away in the input itself, and a prompt with nothing to tap keeps its
autofocus. Nothing changes for a mouse or a trackpad.

* test(prompt): cover the touch autofocus rule

Extracts the decision from 3c6b5f0 into shouldAutofocusPrompt() so it can
be asserted the way activeSuggestionAfterInput() is, and pins the three
cases that matter: touch with a list opts out, touch with nothing to tap
keeps its keyboard, and a fine pointer is never affected. Behavior is
unchanged.

* Fix(cloud): one file can no longer stop sync forever, and settings ask first

A vault could reach a state where every sync ended with "Cloud sync stopped
because .zennotes/vault.json has unsynced local edits", naming a file that
had not been touched in days and whose hash never changed. Reported on
Discord with the diagnostics that made it findable: no write events on the
file, the same error after every restart, and a stack pointing at
assertUnchanged.

Sync refuses to write over a file it cannot vouch for, which is the right
instinct, but it never asked whether the file it was refusing to overwrite
was already the same file. When the change feed carried an item this device
had never tracked and the path existed on disk, it threw without comparing
content, and throwing stopped the run before the cursor was saved, so the
next run fetched the same change and stopped in the same place, forever. The
file it names is not special: the scan sorts by path and a dot sorts first,
so .zennotes/vault.json simply got there ahead of every note.

Three rules replace the refusal. A file already holding exactly what the
change carries is adopted, because both sides agree and there is nothing to
resolve. A file that differs is kept where it is while the incoming version
lands beside it as "Note (cloud conflict).md", numbered when that name is
taken. A delete or a move whose file cannot be vouched for keeps the local
file instead, since nothing arrives with those to park and the local file is
the version being preserved. Each of those reports a conflict and lets the
run continue, so one file can never wedge sync again.

Vault settings are answered rather than merged. A conflict copy inside a
hidden folder is not something anyone can act on, so the cloud's settings
wait at .zennotes/vault.cloud-conflict.json, never synced themselves and
replaced rather than numbered when a newer one arrives, while Cloud settings
asks which side to keep. This device's settings stay in use until the
question is answered, so doing nothing keeps what already works, and taking
the cloud's writes them through the vault's own normalizer, which refuses a
file it cannot read instead of applying half of it.

Two smaller wedges went with it: a remote delete for a file already deleted
locally used to fail the run with a raw ENOENT, and so did a move whose
source had gone.

Both repositories change, the desktop one and the shared portable one behind
mobile and web, since they are a deliberately synced pair. The portable one
already adopted identical content, which is why this only ever bit desktop.

* Fix(editor): Home and End reach the real edge of a wrapped display row (#591)

On a soft-wrapped line, Home and End could land short of the row edge or on
a neighboring row, exactly as a bare $ did before a16300f. The reporter
spotted that it was the same bug wearing different keys, and they were
right: Home and End were never our bindings at all. They fell through to
CodeMirror's cursorLineBoundaryBackward/Forward, which locate the row edge
by hit-testing an x coordinate at the editor's edge, the same resolution
that #575 removed from $ because it walks sub-pixel glyph rects under
fractional display scaling.

ZenNotes gives that hit-test further to travel than plain CodeMirror does.
It probes view.dom, the whole editor element, while the text column is
centered inside it, so the probed x sits well outside the text: 87 pixels
past it on the machine this was measured on. codemirror-vim maps neither
key, so Vim users were on the same CodeMirror path rather than a motion.

Both keys are now bound ahead of defaultKeymap to commands that compute the
boundary from row geometry through displayRowEdge, the helper $, g0, A and I
already use, so no x coordinate is resolved anywhere. Shift extends the
selection the same way, and Mod-Home/Mod-End still fall through to document
start and end. A caret resting exactly on a wrap point belongs to two rows
at once, so, like CodeMirror's own boundary motion, the character before a
backward-associated caret is what gets measured; without that a second End
press walked on to the next row.

displayRowEdge moves out of cm-vim-display-line.ts into cm-display-row.ts,
since it is now shared by the Vim motions and by keys that have nothing to
do with Vim, and this repo would rather move a function than keep a second
copy of it.

The mislanding does not reproduce on a pixel-accurate display, as in #575,
and emulating fractional device scale factors did not provoke it either, so
the verification is structural: unit tests assert exact landings under
jittered row coordinates and that posAtCoords is never called at all, and in
the running app the true wrap points were measured from DOM text rects, with
Home and End landing exactly on every display row in both modes.

* Feat(vim): ]] and [[ jump to the next and previous heading (#578)

Requested after Zed, which maps the same keys, and the keys Vim itself uses
to move between sections. In a note the sections are the headings, so `]]`
goes to the next one and `[[` to the one before.

It is a motion rather than a command, which is what makes it worth having:
`d]]` deletes to the next heading, `v]]` selects to it, `3]]` skips three,
and `Ctrl+O` comes back, none of which needs writing. The headings come from
the parser the outline panel and `Space p` already use, so a `# comment`
inside a code fence or a `#` line in frontmatter is never a destination, and
what the motion stops on is exactly what the outline lists. With no heading
left that way the cursor runs to the end or start of the note, like Vim's
section motions, so the key never sits there doing nothing.

The keys turned out to be unreachable before they could work. VimNav carries
`[b`/`]b` buffer switching as a global fallback for when focus is outside the
editor, and it consumed the first `[` or `]` to arm that sequence, with
preventDefault and stopImmediatePropagation, so codemirror-vim never saw
either press. A bare `]]` did nothing while `2]]` worked, because a pending
count already had an exception carved out for it: the same problem was found
once before for `f[` and patched narrowly. That fallback now stands down for
the whole focused editor, which is where it never belonged: codemirror-vim
has `[b`, `]b`, `gt` and `gT` mapped itself. Any future `[x` or `]x` motion
would have hit the same wall. Buffer and tab keys are mapped in visual
context too, so nothing that used to reach the fallback from a standing
selection loses its binding.

Two suites cover it: the motion directly, and the keys pressed for real
through codemirror-vim, which is the one that matters because `]<character>`
is a built-in Vim motion that `]]` also matches, and only a real keypress
proves which mapping wins. `d]]` leaves a blank line behind, which is Vim's
own rule for an exclusive motion ending in column one, so the test asserts
that rather than pretending otherwise.

* chore(release): 2.28.2

* Fix(editor): LaTeX completion asks which typesetter, and reads dollars in context

Review follow-up to #594, kept out of the contributor's commit.

A note set to the Typst typesetter takes different syntax entirely, so
offering `\frac{}{}` in one was wrong every time. The completion source now
reads the renderer the pane is configured with and stays silent unless it is
KaTeX. The facet already carried the answer; it just had no accessor.

Dollars inside code were being counted as delimiters. A note with `echo $$`
in a shell block flipped the parity for everything below it, so `\` popped
LaTeX commands in plain prose from there on, and a `$5` in inline code did
the same for the rest of its line. Both counts now skip anything the syntax
tree says is code, which is the same question the cursor position was
already being asked.

The block scan also ran from the start of the document on every `\` typed,
allocating the whole prefix as a string. It now looks back a bounded window:
a display block opened further up than that is not a formula anyone is still
typing.

The rendered previews are cached between popups. A bare `\` opens the entire
table at once, and typesetting every visible row through KaTeX on each open
was the one cost this feature could be felt through.

* Fix(editor): a frontmatter Tags: field is the tags field in the editor too

Review follow-up to #595, kept out of the contributor's commit.

`parseFrontmatterFields` lowercases keys, so `Tags:` and `TAGS:` are the tags
field everywhere the vault is indexed: those notes show up under their tags
in the Tags view, in search, in the CLI. The editor read the key
case-sensitively for an inline `tags: a, b` line, so a note written with a
capital T had tags the rest of the app knew about and the editor would
neither chip nor complete. Its own block-list branch already lowercased,
which is how the two halves of one feature came to disagree.

Both places now ask one question, `frontmatterTagsValue`, phrased the way the
shared parser phrases it.

The deeper risk is that two parsers now decide what a frontmatter tag is:
the shared one, which is what gets indexed, and the editor's own scan, which
exists because the shared one returns tags without positions and decorations
need offsets. They cannot be collapsed, so a test pins them together instead:
a matrix of frontmatter shapes (inline list, comma scalar, space scalar,
block list, quoted, `#`-prefixed, capitalized key, and two fields that only
look like tags) asserts the chips a note renders are exactly the tags
`frontmatterTags` returns for it. Whichever side moves next, that test fails.

* Fix(editor): newer note saves always finish last

Two saves for one note could overlap, and the filesystem was free to finish the older body after the newer one. The newer completion had already marked the buffer clean, leaving memory ahead of disk with no retry scheduled.

Queue writes per path while keeping different notes independent. Each queued turn snapshots the latest dirty buffer only after the prior write finishes, so disk order now matches edit order.

* Fix(cloud): settings wait for the user's answer

A first sync classified differing vault settings as an ordinary bootstrap conflict, so it never parked the cloud copy and repeated forever. Established syncs had the opposite problem: they parked the copy, then immediately uploaded local settings before showing the question.

Treat settings specially during bootstrap and expose durable pending paths from each filesystem repository. Mutation planning now leaves those paths untouched while continuing to sync every unrelated file.

* Fix(vault): atomic saves wait out Windows readers

Windows can deny a destination rename while a watcher, indexer, or antivirus scanner has the file open. The atomic writer treated that transient sharing window as a permanent save failure, and the new concurrent-reader test exposed it in CI.

Retry only permission and sharing failures with a short bounded backoff. Both the Go server and Electron writer keep the old file visible until a complete replacement can land, and all other errors still fail immediately.

* Fix(editor): LaTeX completion keeps dollar context

The display-math scan started at an arbitrary 20,000-character boundary. If that boundary split a long formula from its opening delimiter, completion either disappeared inside the formula or appeared in prose after its closing delimiter.

Count display delimiters from the stable start of the document. Long closed and still-open formulas now preserve the same parity as short ones, while code-region filtering remains unchanged.

* Fix(editor): cold grammars get their first scan

vscode-textmate includes one-time scanner compilation in its per-line deadline. Slower Windows runners could therefore mark a tiny valid preview as stopped before any token was emitted.

Retry only a stopped first line once, after compilation has completed, while charging both attempts to the existing wall-clock caps. A deterministic cold-start regression now covers the behavior.

---------

Co-authored-by: Florian-Sébastien Vautier <florian.vautier@etu.univ-grenoble-alpes.fr>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Junerey <junerey.casuga@gmail.com>
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.

2 participants