From 7565112b00dfb9aa9135401374354f7cd4e43fef Mon Sep 17 00:00:00 2001 From: adnayn <167046854+uNyanda@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:58:23 +0200 Subject: [PATCH 1/9] fix(search): Ctrl+P navigates the palette instead of closing it global.searchNotes is bound to Mod+P, which resolves to Ctrl+P on Linux and Windows. That is also the Emacs previous-item chord every palette handles through isPalettePreviousKey(). focusPaneHandler runs capture-phase on window, so its toggle called preventDefault() and stopImmediatePropagation() before React's delegated root listener ever saw the key. The Ctrl+P branch was unreachable while the palette was open. Ctrl+N, Ctrl+J and Ctrl+K were unaffected because none of them is a global binding, which is the whole of the asymmetry. Removes the toggle rather than guarding it. Escape, opening a note and clicking outside all still dismiss the palette, and the in-app manual already describes Mod+P as opening it rather than toggling. #510's capture-phase registration and the open path below are untouched, so Ctrl+P still opens the palette from the editor and still beats Vim's cursor-up. --- packages/app-core/src/App.tsx | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 742fa087e..f94d3b0b7 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -1029,20 +1029,12 @@ function App(): JSX.Element { !!document.querySelector('[data-prompt-modal]') || !!document.querySelector('[data-confirm-modal]') || !!document.querySelector('[data-cloud-conflict-dialog]') - // Search Notes is a toggle: its own shortcut closes the palette it - // opened (#510 moved it here from the bubble handler, which had no - // overlay guard at all). A confirm on top of the palette, such as the - // Ctrl+D trash confirmation, keeps the key to itself. - if ( - state.searchOpen && - !modalOrMenuOpen && - matchesShortcut(e, overrides, 'global.searchNotes') - ) { - e.preventDefault() - e.stopImmediatePropagation() - setSearchOpen(false) - return - } + // `global.searchNotes` is deliberately not a toggle. `Mod+P` resolves to + // `Ctrl+P` on Linux and Windows, which is also the Emacs previous-item + // chord every palette handles via `isPalettePreviousKey()`. A toggle here + // ran capture-phase and called `stopImmediatePropagation()`, making that + // branch unreachable while the palette was open (#933). Escape dismisses. + // Do not reinstate a toggle above the overlay guard. if ( state.settingsOpen || state.whatsNewOpen || From ea5528ea871c5d3a00f26a5a282c964f6f3d2b9a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 17:33:07 -0500 Subject: [PATCH 2/9] Test(editor): a parse that ran out of time reads the rest of the note again After 2.69.1, the note grammar's habit of reading text again looked like waste worth removing. noteMarkdown's outer tree always spans the whole note, so when a state update's 20 ms parse slice runs out, CodeMirror drops the older tree past the point the parse reached, and the parse that resumes reads that text again instead of reusing its blocks. Reusing those blocks was tried, and it is not waste. Lezer's markdown reuse can keep a block whose meaning changed with text outside it: an indented code block whose indent was deleted along with the frontmatter, a paragraph whose end moved when a table row under it broke. The grammar happens to read again exactly where that bites. Checked against a fresh parse 50,000 times over 100,000 random edits, the grammar as it is was stale 3 times, with that reuse 29 times, and plain markdown() 205 times. The time it would save is small: a typed character parses in about 4 ms in an 800 KB note, inside its slice, so a cut needs a slow machine or a pause first, and reading the rest after one costs what opening the note costs, in background slices. So the parser does not change. The header of cm-markdown-language.ts says why both full reads (after a cut, and when the body start moves) stay, and that syntaxTree(state).length is always the whole note, so code that needs to know how far the parse got asks ensureSyntaxTree or syntaxTreeAvailable. A new test pins the read after a cut, next to the one that pins the full parse when the closing fence is typed; both fail on the reusing version. --- .../src/lib/cm-markdown-language.test.ts | 36 +++++++++++++++++-- .../app-core/src/lib/cm-markdown-language.ts | 27 +++++++++++--- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/app-core/src/lib/cm-markdown-language.test.ts b/packages/app-core/src/lib/cm-markdown-language.test.ts index d7fb492c0..26225c482 100644 --- a/packages/app-core/src/lib/cm-markdown-language.test.ts +++ b/packages/app-core/src/lib/cm-markdown-language.test.ts @@ -1,5 +1,5 @@ import { markdownLanguage } from '@codemirror/lang-markdown' -import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' +import { ensureSyntaxTree, syntaxTree, syntaxTreeAvailable } from '@codemirror/language' import { EditorState } from '@codemirror/state' import type { SyntaxNode, Tree } from '@lezer/common' import { describe, expect, it, vi } from 'vitest' @@ -261,8 +261,10 @@ describe('noteMarkdown: incremental parsing', () => { // This one transition reparses the body in full: the Body node moved from // position 0 to the closing fence, and parseMixed (through @lezer/common // 1.5.2) loses track of the old mount when the mounted node is the first - // child covering the new start. It costs one parse, the same as opening - // the note, and the very next edit is incremental again. + // child covering the new start. Keep it that way: blocks reused across a + // moved start can keep a meaning the move changed (the header of + // cm-markdown-language.ts has the numbers). It costs one parse, the same + // as opening the note, and the very next edit is incremental again. expect(blockTreeAt(after, probeAt + 3)).not.toBe(probeTree) const settled = blockTreeAt(after, probeAt + 3) const editAt = next.doc.toString().indexOf('Paragraph 3 ') @@ -279,6 +281,34 @@ describe('noteMarkdown: incremental parsing', () => { expect(names(after)).not.toContain('Frontmatter') expect(names(after)).toContain('HorizontalRule') }) + + it('reads the text past a parse that ran out of time again', () => { + // When an update's 20 ms slice runs out, the whole-note outer tree makes + // CodeMirror drop the older tree past that point, so the parse that + // resumes reads the rest again instead of reusing blocks parsed before + // the edit. That is the safe outcome (the header of + // cm-markdown-language.ts says why); a change that starts reusing them + // should be a decision, not an accident. + const doc = `---\ntitle: x\n---\n${BODY}` + const { state, tree } = parse(doc) + const probeAt = doc.indexOf(PROBE) + const probeTree = blockTreeAt(tree, probeAt) + + const editAt = doc.indexOf('Paragraph 3 ') + // A clock that moves 30 ms per read ends the slice after one step. + let now = Date.now() + const clock = vi.spyOn(Date, 'now').mockImplementation(() => (now += 30)) + let next: EditorState + try { + next = state.update({ changes: { from: editAt, to: editAt, insert: 'Edited ' } }).state + } finally { + clock.mockRestore() + } + expect(syntaxTreeAvailable(next)).toBe(false) + const after = ensureSyntaxTree(next, next.doc.length, 5000)! + expect(blockTreeAt(after, probeAt + 'Edited '.length)).not.toBe(probeTree) + expect(nodes(after)).toEqual(nodes(parse(next.doc.toString()).tree)) + }) }) describe('a lone dash under a line of text (#898)', () => { diff --git a/packages/app-core/src/lib/cm-markdown-language.ts b/packages/app-core/src/lib/cm-markdown-language.ts index 55d0199d2..de31217f1 100644 --- a/packages/app-core/src/lib/cm-markdown-language.ts +++ b/packages/app-core/src/lib/cm-markdown-language.ts @@ -27,10 +27,29 @@ * through `parseMixed`. The mount hands the previous markdown tree to the * inner parser as fragments, so body parsing stays incremental as long as the * body keeps its start: edits anywhere 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; parseMixed cannot find the old mount - * across that move. The test file pins both behaviours. + * reuse the untouched blocks. + * + * Two cases read again text that a plain markdown parser would reuse, and + * both are kept on purpose. When the body start moves (the closing fence + * typed for the first time, or broken), parseMixed cannot find the old mount + * and the body is parsed in full, the same as opening the note. And the outer + * tree always spans the whole note, so when a parse runs out of its 20 ms + * slice, CodeMirror drops the older tree past that point and the parse that + * resumes reads the rest again. Reusing the older blocks in those two cases + * makes the tree go stale more often, because Lezer's markdown reuse can keep + * a block whose meaning changed with text outside it: an indented code block + * whose indent was deleted along with the frontmatter, a paragraph whose end + * moved when a table row under it broke. Checked against a fresh parse 50,000 + * times over 100,000 random edits, this grammar was stale 3 times, the same + * grammar with that reuse 29 times, and plain `markdown()` 205 times. The + * cost is small: a typed character parses well inside its slice even in an + * 800 KB note, and reading the rest after a cut costs what opening the note + * costs, in background slices. The test file pins both behaviours. + * + * One consequence: `syntaxTree(state).length` is always the whole note, so it + * does not say how far the parse got. Ask `ensureSyntaxTree` or + * `syntaxTreeAvailable` instead, as `headingAbove` in table-to-database.ts + * does. * * The frontmatter content is deliberately left untokenized: with Live Preview * off a note reads as its raw text (#616), and the card styles it when on. From e91aa5cd3fb845d329e3ebcc2e7c2585330a8063 Mon Sep 17 00:00:00 2001 From: adnayn <167046854+uNyanda@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:22:12 +0200 Subject: [PATCH 3/9] Fix(quick-capture): the chords Vim swallows now reach their handlers Quick capture renders as its own window root, so App.tsx and its capture-phase handler are never loaded there. Key handling was a plain window listener in the bubble phase, and codemirror-vim consumes Ctrl+P, Ctrl+N and Ctrl+W as normal-mode chords and stops propagation. Three of the four shortcuts the footer advertises were dead in exactly the mode they matter in. The main window already fixed this twice (#510, #579); neither fix reached this window. A capture-phase listener now claims those chords, and only while no overlay is open. Inside an overlay its input has focus and the palette's own isPalettePreviousKey/isPaletteNextKey branch navigates, so standing aside there is what keeps Ctrl+P navigating rather than closing. That is the main window's behaviour, and Ctrl+N always worked there because the overlay guard returns early before newNoteHere. Mod+W needed different treatment, because useCloseWindowShortcut is shared with the main window. It takes a capture flag that only this call site sets; the main window leaves it off, since it already resolves that chord itself and doing both would double-handle it. Three further defects in the same window, all found while testing the first: - The picker rows put the filename last behind the path, so the filename truncated to a stub while the path stayed readable. This is #635, whose fix landed in Open Buffers and never reached here. The row layout now mirrors BufferPalette, and the path column shows only the subpath, since the folder is already labelled on the left and the filename sits beside it. - Neither overlay scrolled the selection into view, so moving past the bottom of the list left the highlight off screen. Both now do, keyed off data-qc-idx to stay clear of SearchPalette's data-search-idx. - The footer is now state-aware, and the command overlay no longer claims Mod+N/Mod+P on rows whose chords move the selection instead. The navigation hint reads Ctrl, not Mod, and that is load-bearing rather than stylistic. isPaletteNextKey and isPalettePreviousKey match on ctrlKey and reject metaKey, so Cmd+N/Cmd+P navigate in no palette on any platform. Every other palette hardcodes the literal for that reason. An earlier draft used modKey here, which would have rendered a Cmd hint that does nothing on macOS. 11 tests added. They dispatch on document for the chords that open or create, so the capture path runs while the editor never sees the key, and on the focused input for navigation inside an overlay. Each puts Vim into normal mode first, since insert mode is where the bug does not reproduce. --- .../src/components/QuickCaptureApp.test.ts | 241 ++++++++++++++++++ .../src/components/QuickCaptureApp.tsx | 145 ++++++++--- packages/app-core/src/lib/close-shortcut.ts | 17 +- 3 files changed, 368 insertions(+), 35 deletions(-) diff --git a/packages/app-core/src/components/QuickCaptureApp.test.ts b/packages/app-core/src/components/QuickCaptureApp.test.ts index ed18c4a35..815250e98 100644 --- a/packages/app-core/src/components/QuickCaptureApp.test.ts +++ b/packages/app-core/src/components/QuickCaptureApp.test.ts @@ -28,6 +28,10 @@ describe('Quick Capture Escape handling (#765)', () => { configurable: true, value: () => new DOMRect() }) + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) @@ -225,6 +229,10 @@ describe('Quick Capture close shortcut (#893)', () => { configurable: true, value: () => new DOMRect() }) + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) @@ -293,6 +301,239 @@ describe('Quick Capture close shortcut (#893)', () => { expect(host.querySelector('input')).not.toBeNull() }) + // The chords below are claimed in a capture-phase listener on window, because + // codemirror-vim consumes Mod+N / Mod+P / Mod+W as normal-mode chords and stops + // propagation, so those chords only reach the bubble listener in insert mode. + // The fix moved them into a capture-phase listener on window. Dispatching on + // document is what makes these tests exercise that phase: the capture path + // still starts at window, so the listener runs, while the editor never sees + // the key and Vim cannot stand in for it. + const onWindow = (init: KeyboardEventInit): Promise => press(document, init) + const modP: KeyboardEventInit = { key: 'p', code: 'KeyP', keyCode: 80, metaKey: true } + const modN: KeyboardEventInit = { key: 'n', code: 'KeyN', keyCode: 78, metaKey: true } + const modShiftP: KeyboardEventInit = { + key: 'p', code: 'KeyP', keyCode: 80, metaKey: true, shiftKey: true + } + // Navigation inside an overlay is Ctrl-only. isPalettePreviousKey requires + // ctrlKey and rejects metaKey outright, on every platform, which is why the + // footer reads Ctrl and not Mod. Mod+N/P only open the overlays. + const ctrlP: KeyboardEventInit = { key: 'p', code: 'KeyP', keyCode: 80, ctrlKey: true } + const ctrlN: KeyboardEventInit = { key: 'n', code: 'KeyN', keyCode: 78, ctrlKey: true } + + const overlayInput = (): HTMLInputElement | null => + host.querySelector('input') + const footerHints = (): string => + (host.querySelector('footer')?.textContent ?? '').replace(/\s+/g, ' ').trim() + const rows = (): HTMLButtonElement[] => + [...host.querySelectorAll('[data-qc-idx]')] + // Exact class token, not a substring match: the inactive row carries + // `hover:bg-paper-200/60`, which contains "bg-paper-200" as a substring. + const activeRow = (): HTMLButtonElement => + rows().find((r) => r.classList.contains('bg-paper-200')) ?? rows()[0] + + /** `window.zen` is installed by defineProperty without `writable`, so seeding + * notes has to go through the same door rather than by assignment. */ + function seedNotes(notes: { path: string; title: string; folder: string }[]): void { + const zen = (window as unknown as { zen: Record }).zen + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + ...zen, + listNotes: vi.fn(async () => + notes.map((n, i) => ({ + ...n, + siblingOrder: i, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + })) + ) + } + }) + } + + /** CodeMirror starts in insert mode here, so one Escape puts Vim in normal + * mode, which is the state where the chords used to be swallowed. */ + async function toNormalMode(view: EditorView): Promise { + await press(view.contentDOM, { key: 'Escape', keyCode: 27 }) + } + + it('opens the note picker on Mod+P in normal mode, where Vim would take it', async () => { + const view = await mount() + await toNormalMode(view) + + await onWindow(modP) + + expect(overlayInput()?.placeholder).toContain('Search notes') + // Vim's cursor-up never ran, and nothing was saved or closed on the way. + expect(windowClose).not.toHaveBeenCalled() + expect(writeNote).not.toHaveBeenCalled() + }) + + it('starts a new note on Mod+N in normal mode', async () => { + const view = await mount() + await toNormalMode(view) + + await onWindow(modN) + + expect(createNote).toHaveBeenCalled() + expect(overlayInput()).toBeNull() + }) + + it('hides the window on Mod+W in normal mode, keeping the draft', async () => { + const view = await mount() + await toNormalMode(view) + + await onWindow(cmdW) + + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('opens the command palette on Mod+Shift+P in normal mode', async () => { + const view = await mount() + await toNormalMode(view) + + await onWindow(modShiftP) + + expect(overlayInput()?.placeholder).toContain('Run a command') + }) + + it('moves down rather than toggling once the picker has focus', async () => { + seedNotes([ + { path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' }, + { path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' } + ]) + await mount() + await onWindow(modP) + expect(rows()).toHaveLength(2) + expect(activeRow().textContent).toContain('Alpha') + + // The chord targets the focused input, which is where a real keystroke + // lands. The window capture listener stands aside while an overlay is + // open, so the overlay's own handler navigates rather than the chord + // re-entering the capture handler and closing the picker. + await press(overlayInput()!, ctrlN) + + expect(overlayInput()?.placeholder).toContain('Search notes') + expect(rows()).toHaveLength(2) + expect(activeRow().textContent).toContain('Beta') + }) + + it('holds at the top row on Ctrl+P instead of closing the picker', async () => { + seedNotes([ + { path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' }, + { path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' } + ]) + await mount() + await onWindow(modP) + const input = overlayInput()! + await press(input, ctrlN) // move off the top row first + + // Ctrl+P is previous. From the top row it clamps rather than toggling the + // overlay shut, which is the specific failure being fixed. + await press(input, ctrlP) + expect(overlayInput()?.placeholder).toContain('Search notes') + expect(activeRow().textContent).toContain('Alpha') + + // And it does go up when there is somewhere to go. + await press(input, ctrlN) + await press(input, ctrlP) + expect(activeRow().textContent).toContain('Alpha') + }) + + it('stops moving at the last row instead of wrapping or closing', async () => { + seedNotes([ + { path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' }, + { path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' } + ]) + await mount() + await onWindow(modP) + const input = overlayInput()! + await press(input, ctrlN) // onto Beta, the last row + await press(input, ctrlN) // one past the end + + expect(rows()).toHaveLength(2) + expect(activeRow().textContent).toContain('Beta') + }) + + it('swaps the footer hints while an overlay is open', async () => { + await mount() + const idle = footerHints() + expect(idle).toContain('notes') + expect(idle).toContain('close') + + await onWindow(modP) + const open = footerHints() + expect(open).toContain('move') + expect(open).toContain('esc close') + // Ctrl, not Mod. Cmd+N/P do not navigate in any palette, so advertising + // them here would be the same lie Bug B was about, reintroduced. + expect(open).toContain('Ctrl+N/P') + expect(open).not.toContain('⌘N/P') + // The chords that no longer apply inside an overlay are not offered. + expect(open).not.toContain('new') + expect(open).not.toContain('cmd') + }) + + it('drops the two command hints that no longer run inside the command palette', async () => { + await mount() + await onWindow(modShiftP) + + const listed = rows().map((r) => r.textContent?.replace(/\s+/g, ' ').trim() ?? '') + expect(listed.some((t) => t.startsWith('Save and start a new note'))).toBe(true) + expect(listed.some((t) => t.startsWith('Open another note'))).toBe(true) + // Mod+Enter is not a navigation chord, so its hint stays. + expect(rows()[0].textContent).toContain('↩') + }) + + it('scrolls the active row into view in both overlays', async () => { + seedNotes([ + { path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' }, + { path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' } + ]) + const scrolled = Element.prototype.scrollIntoView as unknown as ReturnType + + await mount() + + await onWindow(modP) + scrolled.mockClear() + await press(overlayInput()!, ctrlN) + expect(activeRow().textContent).toContain('Beta') + expect(scrolled).toHaveBeenCalledWith({ block: 'nearest' }) + + // Dismiss, then the same check for the command overlay. Its effect is + // keyed on the same `active`, but it is a separate component. + await press(overlayInput()!, { key: 'Escape', keyCode: 27 }) + await onWindow(modShiftP) + scrolled.mockClear() + await press(overlayInput()!, ctrlN) + expect(scrolled).toHaveBeenCalledWith({ block: 'nearest' }) + }) + + it('shows only the subpath beside the filename', async () => { + seedNotes([ + { + path: 'inbox/ZenNotes/Enhancements/Calendar Panel Tasks Placement.md', + title: 'Calendar Panel Tasks Placement', + folder: 'inbox' + } + ]) + await mount() + await onWindow(modP) + + const cells = [...rows()[0].querySelectorAll('span')].map((s) => s.textContent?.trim() ?? '') + expect(cells).toContain('Calendar Panel Tasks Placement') + expect(cells).toContain('ZenNotes/Enhancements') + // Neither the folder nor the filename is repeated in the path column. + expect(cells.filter((c) => c === 'inbox')).toHaveLength(1) + expect(cells.some((c) => c.endsWith('.md'))).toBe(false) + }) + it('follows a rebind of Close active tab', async () => { localStorage.setItem( 'zen:prefs:v2', diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 95b082e3b..684d529b0 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -576,7 +576,7 @@ export function QuickCaptureApp(): JSX.Element { // save, the draft stays in this renderer for the next show. The picker and // the command palette keep it, as palettes do in the main window. const overlayOpen = useCallback(() => overlayRef.current !== 'none', []) - useCloseWindowShortcut(overlayOpen) + useCloseWindowShortcut(overlayOpen, true) const submitAndCloseRef = useRef(submitAndClose) useEffect(() => { submitAndCloseRef.current = submitAndClose @@ -594,21 +594,8 @@ export function QuickCaptureApp(): JSX.Element { void submitAndCloseRef.current() return } - if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'n') { - e.preventDefault() - void newNoteRef.current() - return - } - if (mod && e.shiftKey && e.key.toLowerCase() === 'p') { - e.preventDefault() - setOverlay((current) => (current === 'command' ? 'none' : 'command')) - return - } - if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'p') { - e.preventDefault() - setOverlay((current) => (current === 'search' ? 'none' : 'search')) - return - } + // Mod+N, Mod+P and Mod+Shift+P are handled in the capture-phase listener + // below, which stands aside while an overlay is open. if (e.key === 'Escape') { // CodeMirror can consume Esc (e.g. collapsing a visual selection) // without stopping propagation. Do not also save and hide. (#765) @@ -631,6 +618,40 @@ export function QuickCaptureApp(): JSX.Element { return () => window.removeEventListener('keydown', handler) }, []) + // codemirror-vim consumes Ctrl+P, Ctrl+N and Ctrl+W as normal-mode chords and + // stops propagation, so the bubble handler above never sees them and the + // footer advertises shortcuts that only work in insert mode. Capture phase, + // the same treatment App.tsx gives its own editor chords (#510, #579). + // + // Gated on no overlay being open: with one open its input has focus and its + // own isPalettePreviousKey branch moves the selection, so standing aside here + // is what keeps Ctrl+P navigating instead of closing. + // + // Escape deliberately stays in the bubble handler, whose branch reads + // e.defaultPrevented on the grounds that CodeMirror has already run (#765). + useEffect(() => { + const overlayChords = (e: KeyboardEvent): void => { + if (overlayRef.current !== 'none') return + if (!(e.metaKey || e.ctrlKey) || e.altKey) return + const key = e.key.toLowerCase() + if (e.shiftKey && key === 'p') { + e.preventDefault() + e.stopImmediatePropagation() + setOverlay((current) => (current === 'command' ? 'none' : 'command')) + } else if (!e.shiftKey && key === 'n') { + e.preventDefault() + e.stopImmediatePropagation() + void newNoteRef.current() + } else if (!e.shiftKey && key === 'p') { + e.preventDefault() + e.stopImmediatePropagation() + setOverlay((current) => (current === 'search' ? 'none' : 'search')) + } + } + window.addEventListener('keydown', overlayChords, true) + return () => window.removeEventListener('keydown', overlayChords, true) + }, []) + const isMacPlatform = useMemo(() => { try { return window.zen.platformSync() === 'darwin' @@ -769,15 +790,36 @@ export function QuickCaptureApp(): JSX.Element { {modKey}↩ save - - {modKey}N new - - - {modKey}P notes - - - {modKey}⇧P cmd - + {overlay === 'none' ? ( + <> + + {modKey}N new + + + {modKey}P notes + + + {modKey}⇧P cmd + + + {modKey}W close + + + ) : ( + <> + + {/* Ctrl, not Mod: isPaletteNextKey/isPalettePreviousKey + accept Ctrl+N/P and Ctrl+J/K only, with no Cmd branch. + Same literal string the other palettes use, so the footer + stays true on macOS where Mod+N/P does not navigate. */} + ↑↓{' '} + Ctrl+N/P move + + + esc close + + + )} @@ -802,6 +844,17 @@ interface NotePickerOverlayProps { onCancel: () => void } +/** Where a note sits inside its folder, with neither the folder nor the + * filename repeated. The row already shows the folder on the left and the + * title next to it, so a full path would say the same thing twice and leave + * the title nothing but a truncated stub. */ +function noteSubpath(note: Pick): string { + const within = note.path.startsWith(`${note.folder}/`) + ? note.path.slice(note.folder.length + 1) + : note.path + return within.replace(/[^/]+$/, '').replace(/\/+$/, '') +} + function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): JSX.Element { const [query, setQuery] = useState('') const [active, setActive] = useState(0) @@ -811,6 +864,12 @@ function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): inputRef.current?.focus() }, []) + const listRef = useRef(null) + useEffect(() => { + const el = listRef.current?.querySelector(`[data-qc-idx="${active}"]`) + el?.scrollIntoView({ block: 'nearest' }) + }, [active]) + const searchIndex = useMemo(() => buildNoteSearchIndex(notes), [notes]) const results = useMemo(() => { @@ -858,7 +917,7 @@ function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): className="w-full bg-transparent text-sm outline-none placeholder:text-ink-400" /> -
+
{results.length === 0 ? (
No notes match. Esc to dismiss. @@ -869,6 +928,7 @@ function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): return ( ) }) @@ -912,6 +985,12 @@ function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOv inputRef.current?.focus() }, []) + const listRef = useRef(null) + useEffect(() => { + const el = listRef.current?.querySelector(`[data-qc-idx="${active}"]`) + el?.scrollIntoView({ block: 'nearest' }) + }, [active]) + const all = useMemo( () => [ { @@ -927,15 +1006,18 @@ function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOv keywords: 'save write keep open' }, { + // This overlay only mounts while it is open, and while it is open those + // two chords move the selection rather than running the command. Mod+Enter + // is not a navigation chord, so its hint stays. id: 'new' as CommandAction, label: 'Save and start a new note', - hint: `${modKey}N`, + hint: '', keywords: 'new fresh next another note save' }, { id: 'open' as CommandAction, label: 'Open another note…', - hint: `${modKey}P`, + hint: '', keywords: 'open switch picker find search note' } ], @@ -986,7 +1068,7 @@ function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOv className="w-full bg-transparent text-sm outline-none placeholder:text-ink-400" />
-
+
{results.length === 0 ? (
No commands match.
) : ( @@ -995,6 +1077,7 @@ function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOv return ( ) diff --git a/packages/app-core/src/lib/close-shortcut.ts b/packages/app-core/src/lib/close-shortcut.ts index 219c72a4f..43196f05b 100644 --- a/packages/app-core/src/lib/close-shortcut.ts +++ b/packages/app-core/src/lib/close-shortcut.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { + getKeymapDisplay, matchesSequenceToken, matchesShortcut, normalizeKeymapOverrides, @@ -160,3 +161,25 @@ export function useCloseWindowShortcut( } }, [capture]) } + +/** + * The Close active tab binding as a footer shows it, or the empty string once + * it is unbound, so a window that advertises the key can leave the hint out + * rather than name a key that does nothing. Read and kept current the way + * useCloseWindowShortcut reads its binding. + */ +export function useCloseShortcutDisplay(): string { + const [display, setDisplay] = useState(() => + getKeymapDisplay(readSavedKeymapOverrides(), 'global.closeActiveTab') + ) + useEffect(() => { + const onStorage = (event: StorageEvent): void => { + if (event.key === null || event.key === PREFS_KEY) { + setDisplay(getKeymapDisplay(readSavedKeymapOverrides(), 'global.closeActiveTab')) + } + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + return display +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 76b01367c..9fb2d2ccb 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -559,6 +559,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+P', action: 'Open a note', detail: 'Search the vault and load an existing note into the window to edit it in place.' }, { keys: 'Shift+Mod+P', action: 'Command palette', detail: 'Run a capture command: save, save without hiding, start a new note, or open another note.' }, { keys: 'Mod+W', action: 'Hide without saving', detail: 'Hide the window like its close button. Nothing is saved, and the draft is still there the next time you open the window. This is your Close active tab key, so it follows a rebind.' }, + { keys: 'Ctrl+N / Ctrl+P', action: 'Move in a list', detail: 'With the note picker or the command palette open, move the selection down or up, as the arrow keys do. While no list is open they start a new note and open the picker, as above.' }, { keys: 'Esc', action: 'Dismiss', detail: 'Close an open overlay; otherwise save and hide the window.' } ] }, From 4ac1f637dc7f90f2b5662dd9b09795d133bf619a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 12:29:28 -0500 Subject: [PATCH 6/9] Chore(search): the Search Notes comment names #938, the issue it fixes Review follow-up to #939, kept apart so @uNyanda's commit stands as theirs. The comment that explains why Search Notes is no longer a toggle cited #933, the New note form footer PR, for the Ctrl+P report. The report is #938. Behavior is unchanged. Checked in the built app on macOS: Cmd+P opens Search Notes, Ctrl+N and Ctrl+P move within it, Cmd+P inside it no longer closes it, and Escape does. --- packages/app-core/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index f94d3b0b7..d9ff4f32c 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -1033,7 +1033,7 @@ function App(): JSX.Element { // `Ctrl+P` on Linux and Windows, which is also the Emacs previous-item // chord every palette handles via `isPalettePreviousKey()`. A toggle here // ran capture-phase and called `stopImmediatePropagation()`, making that - // branch unreachable while the palette was open (#933). Escape dismisses. + // branch unreachable while the palette was open (#938). Escape dismisses. // Do not reinstate a toggle above the overlay guard. if ( state.settingsOpen || From fb4177b73f64d2f798e07b0bdeba181491e6c5a4 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 13:41:43 -0500 Subject: [PATCH 7/9] Release(cli): bundle the verified 0.8.0 terminal release The bundled CLI is the floor a desktop build guarantees, and 0.8.0 is the newest release. It carries two zn releases into a desktop-managed install from the first launch instead of the first daily update check: 0.7.2, where `zn task` and `zn mcp` read tasks in numbered lists and quotes the way the app does, and 0.8.0, which adds `zn task watch` (one line per task status change, for scripts that react to a card moving, ZenNotes/zennotes#941) and gives each task's status in `zn task list --json` and in `list_tasks`. The pin is the release's terminal-release.json copied byte for byte after it verified against packaging/release-signing/zn-release-1.pub in ZenNotes/tui (source commit 58b0e56, integration protocol 1, all four archives matching checksums.txt). Staged through `npm run terminal:stage`; the native darwin-arm64 binary reports zn v0.8.0. The in-app manual's CLI card says 0.8.0 and what it changes, and the Tasks and folders card describes `zn task watch`. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- packages/app-core/src/lib/help.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index e79d48648..a106126af 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.7.1", - "commit": "91bb33b42a3d156c2e577762b1f7ce1feb02d586", + "version": "0.8.0", + "commit": "58b0e5683bdc35049e7e592998a3f236093e7557", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_darwin_arm64.tar.gz", - "sha256": "cb0c90410fcd86972b4e62a24840bd0f5d2d82d403cfc556399e3c735b43651c" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_darwin_arm64.tar.gz", + "sha256": "29ebd83447c45a8fd5f8196f2c6680521bc1a2bbbec6afa609fadec480006042" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_darwin_amd64.tar.gz", - "sha256": "c6df5efe8d18dfa598e1ce8698fdd1a2eccf76892d94533479838cdd9a3e5d37" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_darwin_amd64.tar.gz", + "sha256": "7d6e91766664d72bb938e20644ab2501d8691aba4ae82168f7d18741850ef22e" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_linux_arm64.tar.gz", - "sha256": "1151c6daed55825e881014873802c32c056dff68797e64ed83451b9fdfe3f0b9" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_linux_arm64.tar.gz", + "sha256": "b73d9356b65a597364ba09bcb086be1b37b24c7c4cd2e806e990b00e8a997f5f" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_linux_amd64.tar.gz", - "sha256": "b61d145931c32b417eaf5f8bce379e7f94e327e3b5527f7868dcba3a3a178ac2" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_linux_amd64.tar.gz", + "sha256": "5f5484434dda2c4745529f4c54c955c678219ce05880e6ff3c25a5af8845b2b1" } } } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 9fb2d2ccb..6e6c1d8e9 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1264,7 +1264,7 @@ export const HELP_CLI: HelpCard[] = [ { title: 'CLI 0.7.1 in desktop 2.69.0', body: - 'On macOS and Linux, desktop 2.69.0 bundles CLI 0.7.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.70.0 bundles CLI 0.8.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'Updates arrive without a ZenNotes update', @@ -1314,7 +1314,7 @@ export const HELP_CLI: HelpCard[] = [ { title: 'Tasks and folders', body: - '`zn task list` enumerates open checkboxes across the vault with stable ids. `zn task toggle ` flips a task without opening the note. `zn folder list / create / rename / delete` keep your subfolder tree manageable from the terminal.' + '`zn task list` enumerates open checkboxes across the vault with stable ids; with `--json`, each task also carries its status. `zn task toggle ` flips a task without opening the note. `zn task watch` prints a line each time a task’s status changes, with the previous and the new status: a card moved on the Kanban board, a task note’s `status:` or a line’s `@status:` edited, or a box checked. With `--json` each change is one line of JSON, so a script can start an agent when a task becomes ready; zn only reports, and whatever runs is your own script. `zn folder list / create / rename / delete` keep your subfolder tree manageable from the terminal.' }, { title: 'MCP for AI agents', From 35d4df8f41df7be9da43867d5954cabc7935c0bc Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 13:41:48 -0500 Subject: [PATCH 8/9] Release: align desktop and shared packages at 2.70.0 --- apps/desktop/package.json | 2 +- apps/quicklook/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 20 ++++++++++---------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 71838b3fa..6b8f49c3e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.69.1", + "version": "2.70.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index e50e0dfbc..63ec424ae 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.69.1", + "version": "2.70.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index a41bf1cb8..de1295db8 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.69.1", + "version": "2.70.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 8debf4ccb..cf7a473fa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.69.1", + "version": "2.70.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index fa4f5c5ba..6e55e90b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.69.1", + "version": "2.70.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.69.1", + "version": "2.70.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.69.1", + "version": "2.70.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -987,7 +987,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16390,7 +16390,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16478,14 +16478,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.69.1", + "version": "2.70.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16499,7 +16499,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.69.1" + "version": "2.70.0" } } } diff --git a/package.json b/package.json index 191ae6aaf..5ca3b4b7a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.69.1", + "version": "2.70.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 18f3b29fd..11990a27e 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.69.1", + "version": "2.70.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 737a8478f..4e3b4f685 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.69.1", + "version": "2.70.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 4d4fc69ee..b3212d973 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.69.1", + "version": "2.70.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 3bc0345b4..09d3ed6d0 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.69.1", + "version": "2.70.0", "type": "module", "exports": { ".": "./src/index.ts" From 9e50b2450e3ac3eb38ca1a3465ec76777838fc35 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 13:46:36 -0500 Subject: [PATCH 9/9] Docs(whats-new): the 2.70.0 highlights What's New opens on the first launch after the update with four highlights. First, zn task watch in the bundled zn 0.8.0 (macOS and Linux, where ZenNotes bundles the CLI). Second, the task status that list_tasks now returns through MCP. Third, Quick capture's shortcuts with Vim in normal mode, naming where each key was broken: Ctrl+P and Ctrl+N everywhere, Ctrl+W only on Linux and Windows, where it is the close key. Fourth, Ctrl+P in Search Notes on Linux and Windows, the only platforms where it used to close the palette. --- packages/app-core/src/lib/whats-new.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index 2b5ed8880..d0ddda04b 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,27 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.70.0', + highlights: [ + { + title: 'React to task changes', + body: 'The zn CLI that ZenNotes bundles on macOS and Linux is now 0.8.0. Its zn task watch prints a line each time a task’s status changes, so a script can act the moment a card moves to another column.' + }, + { + title: 'Assistants see the column', + body: 'Through MCP, list_tasks now gives each task’s status: the column it sits in when the Kanban board is grouped by Custom status.' + }, + { + title: 'Quick capture with Vim', + body: 'The Quick capture window’s shortcuts now work while Vim is in normal mode: Ctrl+P and Ctrl+N everywhere, and Ctrl+W on Linux and Windows. Its note picker shows each note’s name first and keeps the selection in view.' + }, + { + title: 'Ctrl+P in Search Notes', + body: 'On Linux and Windows, Ctrl+P in Search Notes moves the selection up, as in every other list, instead of closing it. Escape closes it.' + } + ] + }, { version: '2.69.1', highlights: [