diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx
index 2e7a3000..09d63e10 100644
--- a/packages/app-core/src/App.tsx
+++ b/packages/app-core/src/App.tsx
@@ -14,6 +14,7 @@ import { requestPaneMode } from './lib/pane-mode'
import { recordRendererPerf } from './lib/perf'
import { focusEditorNormalMode } from './lib/editor-focus'
import { installMarkdownFileDropHandler } from './lib/markdown-file-drop'
+import { shouldBlockGlobalPaletteNavShortcut } from './lib/palette-nav-shortcuts'
import {
appUpdateNoticeLabel,
appUpdatePrimaryActionLabel,
@@ -475,6 +476,11 @@ function App(): JSX.Element {
}
}
}
+ if (shouldBlockGlobalPaletteNavShortcut(e)) {
+ e.preventDefault()
+ e.stopPropagation()
+ return
+ }
if (matchesShortcut(e, overrides, 'global.commandPalette')) {
// ⇧⌘P — command palette
diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx
index fb445f1d..a99b9e6b 100644
--- a/packages/app-core/src/components/BufferPalette.tsx
+++ b/packages/app-core/src/components/BufferPalette.tsx
@@ -12,7 +12,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useStore } from '../store'
import { rankItems } from '../lib/fuzzy-score'
-import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav'
+import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav'
import {
allLeaves,
findLeafWithActiveTab,
@@ -200,6 +200,7 @@ export function BufferPalette(): JSX.Element {
const setActivePane = useStore((s) => s.setActivePane)
const focusTabInPane = useStore((s) => s.focusTabInPane)
+ const paletteNavKeys = useStore((s) => s.paletteNavKeys)
// Select primitives separately so each selector returns a stable
// reference; compute the derived entries list with useMemo. Returning
// a freshly-built array from a single selector would trip zustand's
@@ -343,7 +344,7 @@ export function BufferPalette(): JSX.Element {
↑↓{' '}
- Ctrl+N/P move
+ {paletteNavHintLabel(paletteNavKeys)} move
↵ switch
diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx
index d23bf427..32a559a5 100644
--- a/packages/app-core/src/components/CommandPalette.tsx
+++ b/packages/app-core/src/components/CommandPalette.tsx
@@ -12,7 +12,7 @@ import {
RECENT_COMMAND_COUNT
} from '../lib/command-history'
import { rankItems } from '../lib/fuzzy-score'
-import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav'
+import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav'
import { THEMES, type ThemeFamily, type ThemeMode, type ThemeOption } from '../lib/themes'
import {
buildVaultSwitcherEntries,
@@ -36,6 +36,7 @@ export function CommandPalette(): JSX.Element {
const remoteWorkspaceProfiles = useStore((s) => s.remoteWorkspaceProfiles)
const currentVault = useStore((s) => s.vault)
const workspaceMode = useStore((s) => s.workspaceMode)
+ const paletteNavKeys = useStore((s) => s.paletteNavKeys)
const remoteWorkspaceInfo = useStore((s) => s.remoteWorkspaceInfo)
const initialMode = useStore((s) => s.commandPaletteInitialMode)
const refreshLocalVaults = useStore((s) => s.refreshLocalVaults)
@@ -438,7 +439,7 @@ export function CommandPalette(): JSX.Element {
↑↓{' '}
- Ctrl+N/P move
+ {paletteNavHintLabel(paletteNavKeys)} move
↵{' '}
diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx
index d2e1672f..a9f6e853 100644
--- a/packages/app-core/src/components/Editor.tsx
+++ b/packages/app-core/src/components/Editor.tsx
@@ -61,6 +61,18 @@ const DEFAULT_VIM_MAPPINGS_TO_CLEAR = [
'zR'
]
+/**
+ * Mappings that apply in ALL contexts (no `context` property set) and
+ * conflict with native keybindings. `Vim.unmap(key, 'insert')` can't
+ * remove them because the vim unmap check (`entry.context === ctx`)
+ * fails when both are undefined. Pass no ctx so `undefined === undefined`
+ * matches the context-less entry.
+ */
+const VIM_MAPPINGS_TO_CLEAR_ALL_CONTEXTS = [
+ '',
+ ''
+]
+
function clearKnownVimMappings(): void {
for (const binding of DEFAULT_VIM_MAPPINGS_TO_CLEAR) {
try {
@@ -69,6 +81,13 @@ function clearKnownVimMappings(): void {
/* ignore */
}
}
+ for (const binding of VIM_MAPPINGS_TO_CLEAR_ALL_CONTEXTS) {
+ try {
+ Vim.unmap(binding, undefined as unknown as string)
+ } catch {
+ /* ignore */
+ }
+ }
}
function toVimKeyName(base: string): string {
diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx
index 205bd75d..c54ebe71 100644
--- a/packages/app-core/src/components/EditorPane.tsx
+++ b/packages/app-core/src/components/EditorPane.tsx
@@ -60,7 +60,7 @@ import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@code
import { headingFolding } from '../lib/cm-heading-fold'
import { tags as t } from '@lezer/highlight'
import { searchKeymap } from '@codemirror/search'
-import { autocompletion, completionKeymap } from '@codemirror/autocomplete'
+import { autocompletion, completionKeymap, completionStatus, moveCompletionSelection } from '@codemirror/autocomplete'
import { useStore } from '../store'
import type { LineNumberMode } from '../store'
import type { PaneEdge, PaneLeaf } from '../lib/pane-layout'
@@ -110,6 +110,7 @@ import {
} from '../lib/editor-hydration'
import { recordRendererPerf } from '../lib/perf'
import { rememberTabScroll, recallTabScroll } from '../lib/tab-scroll-memory'
+import { isPaletteNavShortcutKey, isPaletteNextKey, isPalettePreviousKey, paletteCompletionKeymaps, paletteNavModeClass } from '../lib/palette-nav'
import { parseOutline } from '../lib/outline'
import {
findRenderedHeadingForOutlineLine,
@@ -1298,10 +1299,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
override: [slashCommandSource, dateShortcutSource, wikilinkSource],
addToOptions: [{ render: slashCommandRender.render, position: 0 }],
icons: false,
- optionClass: (completion) =>
- (completion as { _kind?: string })._kind === 'wikilink'
- ? 'wikilink-cmd-option'
- : 'slash-cmd-option'
+ optionClass: (completion) => {
+ if ((completion as { _kind?: string })._kind !== 'wikilink') return 'slash-cmd-option'
+ return `wikilink-cmd-option ${paletteNavModeClass(useStore.getState().paletteNavKeys)}`
+ }
}),
keymap.of([
{
@@ -1317,7 +1318,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
...defaultKeymap,
...historyKeymap,
...searchKeymap,
- ...completionKeymap
+ ...completionKeymap,
+ ...paletteCompletionKeymaps()
]),
EditorView.domEventHandlers({
mousedown: (event) => {
@@ -1378,6 +1380,23 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
return true
}
}
+ if (completionStatus(view.state) === 'active') {
+ if (isPaletteNextKey(event)) {
+ moveCompletionSelection(true)(view)
+ event.preventDefault()
+ return true
+ }
+ if (isPalettePreviousKey(event)) {
+ moveCompletionSelection(false)(view)
+ event.preventDefault()
+ return true
+ }
+ if (isPaletteNavShortcutKey(event)) {
+ event.preventDefault()
+ event.stopPropagation()
+ return true
+ }
+ }
if (event.key !== 'Escape') return false
if (!state.vimMode) return false
const cm = getCM(view)
diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx
index edb86f66..c8e9ba1c 100644
--- a/packages/app-core/src/components/OutlinePalette.tsx
+++ b/packages/app-core/src/components/OutlinePalette.tsx
@@ -9,7 +9,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useStore } from '../store'
import { rankItems } from '../lib/fuzzy-score'
-import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav'
+import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav'
import { parseOutline, type OutlineItem } from '../lib/outline'
import { isHelpTabPath } from '@shared/help'
import { isArchiveTabPath } from '@shared/archive'
@@ -36,6 +36,7 @@ export function OutlinePalette(): JSX.Element {
const setOpen = useStore((s) => s.setOutlinePaletteOpen)
const selectedPath = useStore((s) => s.selectedPath)
const noteContents = useStore((s) => s.noteContents)
+ const paletteNavKeys = useStore((s) => s.paletteNavKeys)
const body =
selectedPath && !isVirtualPath(selectedPath) ? noteContents[selectedPath]?.body ?? '' : ''
@@ -140,7 +141,7 @@ export function OutlinePalette(): JSX.Element {
↑↓{' '}
- Ctrl+N/P move
+ {paletteNavHintLabel(paletteNavKeys)} move
↵ jump
diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx
index 366bb71b..22cc0bc1 100644
--- a/packages/app-core/src/components/PinnedReferencePane.tsx
+++ b/packages/app-core/src/components/PinnedReferencePane.tsx
@@ -40,6 +40,7 @@ import { livePreviewPlugin } from '../lib/cm-live-preview'
import { headingFolding } from '../lib/cm-heading-fold'
import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands'
import { dateShortcutSource } from '../lib/cm-date-shortcuts'
+import { paletteCompletionKeymaps, paletteNavModeClass } from '../lib/palette-nav'
import { wikilinkSource } from '../lib/cm-wikilinks'
import { classifyLocalAssetHref, type LocalAssetKind } from '../lib/local-assets'
import { LazyPreview as Preview } from './LazyPreview'
@@ -196,10 +197,10 @@ export function PinnedReferencePane(): JSX.Element | null {
override: [slashCommandSource, dateShortcutSource, wikilinkSource],
addToOptions: [{ render: slashCommandRender.render, position: 0 }],
icons: false,
- optionClass: (completion) =>
- (completion as { _kind?: string })._kind === 'wikilink'
- ? 'wikilink-cmd-option'
- : 'slash-cmd-option'
+ optionClass: (completion) => {
+ if ((completion as { _kind?: string })._kind !== 'wikilink') return 'slash-cmd-option'
+ return `wikilink-cmd-option ${paletteNavModeClass(useStore.getState().paletteNavKeys)}`
+ }
}),
keymap.of([
{
@@ -215,7 +216,8 @@ export function PinnedReferencePane(): JSX.Element | null {
...defaultKeymap,
...historyKeymap,
...searchKeymap,
- ...completionKeymap
+ ...completionKeymap,
+ ...paletteCompletionKeymaps()
]),
EditorView.updateListener.of((upd) => {
if (!upd.docChanged) return
diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx
index cbf3a8b4..2612563c 100644
--- a/packages/app-core/src/components/SearchPalette.tsx
+++ b/packages/app-core/src/components/SearchPalette.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useStore } from '../store'
import type { NoteMeta } from '@shared/ipc'
-import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav'
+import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav'
import {
buildNoteSearchIndex,
parseNoteSearchQuery,
@@ -14,6 +14,7 @@ export function SearchPalette(): JSX.Element {
const notes = useStore((s) => s.notes)
const setSearchOpen = useStore((s) => s.setSearchOpen)
const selectNote = useStore((s) => s.selectNote)
+ const paletteNavKeys = useStore((s) => s.paletteNavKeys)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const inputRef = useRef(null)
@@ -124,7 +125,7 @@ export function SearchPalette(): JSX.Element {
↑↓{' '}
- Ctrl+N/P move
+ {paletteNavHintLabel(paletteNavKeys)} move
↵ open
diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx
index 005443b5..009e736a 100644
--- a/packages/app-core/src/components/SettingsModal.tsx
+++ b/packages/app-core/src/components/SettingsModal.tsx
@@ -19,7 +19,7 @@ import {
type McpServerRuntime
} from '@shared/mcp-clients'
import { useStore } from '../store'
-import type { LineNumberMode, WhichKeyHintMode } from '../store'
+import type { LineNumberMode, PaletteNavKeys, WhichKeyHintMode } from '../store'
import type { KeymapDefinition, KeymapId, KeymapOverrides } from '../lib/keymaps'
import {
formatKeymapBinding,
@@ -201,6 +201,8 @@ export function SettingsModal(): JSX.Element {
const setVimMode = useStore((s) => s.setVimMode)
const vimInsertEscape = useStore((s) => s.vimInsertEscape)
const setVimInsertEscape = useStore((s) => s.setVimInsertEscape)
+ const paletteNavKeys = useStore((s) => s.paletteNavKeys)
+ const setPaletteNavKeys = useStore((s) => s.setPaletteNavKeys)
const keymapOverrides = useStore((s) => s.keymapOverrides)
const setKeymapBinding = useStore((s) => s.setKeymapBinding)
const resetAllKeymaps = useStore((s) => s.resetAllKeymaps)
@@ -855,6 +857,12 @@ export function SettingsModal(): JSX.Element {
description: 'Map a key sequence like jk or jj to Escape in insert mode.',
keywords: ['vim', 'jk', 'jj', 'escape', 'insert mode', 'esc']
},
+ {
+ id: 'palette-navigation-keys',
+ title: 'Panel navigation keys',
+ description: 'Choose whether Ctrl+N/P, Ctrl+J/K, or both move through panels and editor completions.',
+ keywords: ['palette', 'panel', 'completion', 'ctrl n', 'ctrl p', 'ctrl j', 'ctrl k', 'navigation']
+ },
{
id: 'leader-key-hints',
title: 'Leader key hints',
@@ -972,6 +980,18 @@ export function SettingsModal(): JSX.Element {
settingId="vim-insert-escape"
onChange={(next) => setVimInsertEscape(next ?? '')}
/>
+ setPaletteNavKeys(next as PaletteNavKeys)}
+ />
'slash-cmd-option'
}),
- keymap.of([indentWithTab, ...completionKeymap, ...defaultKeymap, ...historyKeymap]),
+ keymap.of([indentWithTab, ...completionKeymap, ...paletteCompletionKeymaps(), ...defaultKeymap, ...historyKeymap]),
editorTheme,
EditorView.updateListener.of((upd) => {
if (upd.docChanged) setRaw(upd.state.doc.toString())
diff --git a/packages/app-core/src/components/TemplatePalette.tsx b/packages/app-core/src/components/TemplatePalette.tsx
index 941a67e3..d4d6f615 100644
--- a/packages/app-core/src/components/TemplatePalette.tsx
+++ b/packages/app-core/src/components/TemplatePalette.tsx
@@ -9,7 +9,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useStore } from '../store'
import { rankItems } from '../lib/fuzzy-score'
-import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav'
+import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav'
import { focusEditorNormalMode } from '../lib/editor-focus'
import { BUILTIN_TEMPLATES } from '@shared/builtin-templates'
import { mergeTemplates } from '@shared/template-files'
@@ -21,6 +21,7 @@ export function TemplatePalette(): JSX.Element {
const createFromTemplate = useStore((s) => s.createFromTemplate)
const customTemplates = useStore((s) => s.customTemplates)
const mode = useStore((s) => s.templatePaletteMode)
+ const paletteNavKeys = useStore((s) => s.paletteNavKeys)
const templates = useMemo(
() => mergeTemplates(BUILTIN_TEMPLATES, customTemplates),
@@ -130,7 +131,7 @@ export function TemplatePalette(): JSX.Element {
↑↓{' '}
- Ctrl+N/P move
+ {paletteNavHintLabel(paletteNavKeys)} move
↵ create
diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx
index 09531bc3..16d4ecdf 100644
--- a/packages/app-core/src/components/VaultTextSearchPalette.tsx
+++ b/packages/app-core/src/components/VaultTextSearchPalette.tsx
@@ -6,7 +6,7 @@ import type {
} from '@shared/ipc'
import { useStore } from '../store'
import { resolveSystemFolderLabels } from '../lib/system-folder-labels'
-import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav'
+import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav'
import { recordRendererPerf } from '../lib/perf'
import { focusEditorNormalMode } from '../lib/editor-focus'
import { Modal } from './ui/Modal'
@@ -145,6 +145,7 @@ export function VaultTextSearchPalette(): JSX.Element {
const backend = useStore((s) => s.vaultTextSearchBackend)
const ripgrepBinaryPath = useStore((s) => s.ripgrepBinaryPath)
const fzfBinaryPath = useStore((s) => s.fzfBinaryPath)
+ const paletteNavKeys = useStore((s) => s.paletteNavKeys)
const [capabilities, setCapabilities] = useState(null)
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
@@ -466,7 +467,7 @@ export function VaultTextSearchPalette(): JSX.Element {
↑↓{' '}
- Ctrl+N/P move
+ {paletteNavHintLabel(paletteNavKeys)} move
↵ open
diff --git a/packages/app-core/src/lib/palette-nav-shortcuts.ts b/packages/app-core/src/lib/palette-nav-shortcuts.ts
new file mode 100644
index 00000000..67e108b0
--- /dev/null
+++ b/packages/app-core/src/lib/palette-nav-shortcuts.ts
@@ -0,0 +1,22 @@
+type PaletteKeyboardEvent = Pick<
+ KeyboardEvent,
+ 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'
+>
+
+export function isPaletteNavShortcutKey(event: PaletteKeyboardEvent): boolean {
+ return (
+ event.ctrlKey &&
+ !event.metaKey &&
+ !event.altKey &&
+ !event.shiftKey &&
+ ['n', 'p', 'j', 'k'].includes(event.key.toLowerCase())
+ )
+}
+
+export function shouldBlockGlobalPaletteNavShortcut(event: KeyboardEvent, doc: Document = document): boolean {
+ if (!isPaletteNavShortcutKey(event)) return false
+ const active = doc.activeElement
+ if (!(active instanceof HTMLElement)) return false
+ if (active.closest('.cm-editor') === null) return false
+ return doc.querySelector('.cm-tooltip-autocomplete') !== null
+}
diff --git a/packages/app-core/src/lib/palette-nav.test.ts b/packages/app-core/src/lib/palette-nav.test.ts
new file mode 100644
index 00000000..3c72fd42
--- /dev/null
+++ b/packages/app-core/src/lib/palette-nav.test.ts
@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ isPaletteNavShortcutKey,
+ isPaletteNextKey,
+ isPalettePreviousKey,
+ paletteNavHintLabel,
+ paletteNavModeClass
+} from './palette-nav'
+import { useStore } from '../store'
+import { shouldBlockGlobalPaletteNavShortcut } from './palette-nav-shortcuts'
+
+function keyEvent(key: string) {
+ return {
+ key,
+ ctrlKey: true,
+ metaKey: false,
+ altKey: false,
+ shiftKey: false,
+ stopPropagation: vi.fn()
+ }
+}
+
+function domKeyEvent(key: string) {
+ return new KeyboardEvent('keydown', { key, ctrlKey: true })
+}
+
+describe('palette navigation keys', () => {
+ beforeEach(() => {
+ useStore.setState({ paletteNavKeys: 'both' })
+ })
+
+ it('formats the footer hint for each mode', () => {
+ expect(paletteNavHintLabel('ctrl-np')).toBe('Ctrl+N/P')
+ expect(paletteNavHintLabel('ctrl-jk')).toBe('Ctrl+J/K')
+ expect(paletteNavHintLabel('both')).toBe('Ctrl+N/P or J/K')
+ })
+
+ it('formats the CSS mode class for wikilink completion hints', () => {
+ expect(paletteNavModeClass('ctrl-np')).toBe('palette-nav-ctrl-np')
+ expect(paletteNavModeClass('ctrl-jk')).toBe('palette-nav-ctrl-jk')
+ expect(paletteNavModeClass('both')).toBe('palette-nav-both')
+ })
+
+ it('recognizes unconfigured completion navigation shortcuts for propagation blocking', () => {
+ useStore.setState({ paletteNavKeys: 'ctrl-jk' })
+
+ expect(isPalettePreviousKey(keyEvent('p'))).toBe(false)
+ expect(isPaletteNavShortcutKey(keyEvent('p'))).toBe(true)
+ })
+
+ it('blocks global palette shortcuts only while a CodeMirror autocomplete is active', () => {
+ const editor = document.createElement('div')
+ editor.className = 'cm-editor'
+ editor.tabIndex = -1
+ const tooltip = document.createElement('div')
+ tooltip.className = 'cm-tooltip-autocomplete'
+ document.body.append(editor, tooltip)
+ editor.focus()
+
+ expect(shouldBlockGlobalPaletteNavShortcut(domKeyEvent('p'))).toBe(true)
+
+ tooltip.remove()
+ expect(shouldBlockGlobalPaletteNavShortcut(domKeyEvent('p'))).toBe(false)
+
+ editor.remove()
+ })
+
+ it('uses Ctrl+N/P only when configured', () => {
+ useStore.setState({ paletteNavKeys: 'ctrl-np' })
+
+ expect(isPaletteNextKey(keyEvent('n'))).toBe(true)
+ expect(isPalettePreviousKey(keyEvent('p'))).toBe(true)
+ expect(isPaletteNextKey(keyEvent('j'))).toBe(false)
+ expect(isPalettePreviousKey(keyEvent('k'))).toBe(false)
+ })
+
+ it('uses Ctrl+J/K only when configured', () => {
+ useStore.setState({ paletteNavKeys: 'ctrl-jk' })
+
+ expect(isPaletteNextKey(keyEvent('j'))).toBe(true)
+ expect(isPalettePreviousKey(keyEvent('k'))).toBe(true)
+ expect(isPaletteNextKey(keyEvent('n'))).toBe(false)
+ expect(isPalettePreviousKey(keyEvent('p'))).toBe(false)
+ })
+})
diff --git a/packages/app-core/src/lib/palette-nav.ts b/packages/app-core/src/lib/palette-nav.ts
index b3314d49..1b425fdd 100644
--- a/packages/app-core/src/lib/palette-nav.ts
+++ b/packages/app-core/src/lib/palette-nav.ts
@@ -1,17 +1,68 @@
import type { KeyboardEvent } from 'react'
+import { completionStatus, moveCompletionSelection } from '@codemirror/autocomplete'
+import { useStore, type PaletteNavKeys } from '../store'
+import { isPaletteNavShortcutKey } from './palette-nav-shortcuts'
-export function isPaletteNextKey(event: KeyboardEvent): boolean {
- const key = event.key.toLowerCase()
- return (
+type PaletteKeyboardEvent = Pick<
+ KeyboardEvent | globalThis.KeyboardEvent,
+ 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey' | 'stopPropagation'
+>
+
+export function paletteNavHintLabel(keys: PaletteNavKeys): string {
+ if (keys === 'ctrl-jk') return 'Ctrl+J/K'
+ if (keys === 'ctrl-np') return 'Ctrl+N/P'
+ return 'Ctrl+N/P or J/K'
+}
+
+export function paletteNavModeClass(keys: PaletteNavKeys): string {
+ return `palette-nav-${keys}`
+}
+
+function paletteNavKeys(): PaletteNavKeys {
+ return useStore.getState().paletteNavKeys
+}
+
+export { isPaletteNavShortcutKey } from './palette-nav-shortcuts'
+
+function isPlainCtrlKey(event: PaletteKeyboardEvent, key: string): boolean {
+ return isPaletteNavShortcutKey(event) && event.key.toLowerCase() === key
+}
+
+export function isPaletteNextKey(event: PaletteKeyboardEvent): boolean {
+ const keys = paletteNavKeys()
+ const match =
event.key === 'ArrowDown' ||
- (event.ctrlKey && !event.metaKey && !event.altKey && key === 'n')
- )
+ ((keys === 'ctrl-np' || keys === 'both') && isPlainCtrlKey(event, 'n')) ||
+ ((keys === 'ctrl-jk' || keys === 'both') && isPlainCtrlKey(event, 'j'))
+ if (match) event.stopPropagation()
+ return match
}
-export function isPalettePreviousKey(event: KeyboardEvent): boolean {
- const key = event.key.toLowerCase()
- return (
+export function isPalettePreviousKey(event: PaletteKeyboardEvent): boolean {
+ const keys = paletteNavKeys()
+ const match =
event.key === 'ArrowUp' ||
- (event.ctrlKey && !event.metaKey && !event.altKey && key === 'p')
- )
+ ((keys === 'ctrl-np' || keys === 'both') && isPlainCtrlKey(event, 'p')) ||
+ ((keys === 'ctrl-jk' || keys === 'both') && isPlainCtrlKey(event, 'k'))
+ if (match) event.stopPropagation()
+ return match
+}
+
+function completionMove(forward: boolean, key: PaletteNavKeys) {
+ return (view: import('@codemirror/view').EditorView): boolean => {
+ const keys = paletteNavKeys()
+ const active = completionStatus(view.state) === 'active'
+ if (keys !== key && keys !== 'both') return active
+ if (!active) return false
+ return moveCompletionSelection(forward)(view)
+ }
+}
+
+export function paletteCompletionKeymaps(): { key: string; run: (view: import('@codemirror/view').EditorView) => boolean }[] {
+ return [
+ { key: 'Ctrl-n', run: completionMove(true, 'ctrl-np') },
+ { key: 'Ctrl-p', run: completionMove(false, 'ctrl-np') },
+ { key: 'Ctrl-j', run: completionMove(true, 'ctrl-jk') },
+ { key: 'Ctrl-k', run: completionMove(false, 'ctrl-jk') }
+ ]
}
diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts
index a77c9a50..4c0e43fe 100644
--- a/packages/app-core/src/store.ts
+++ b/packages/app-core/src/store.ts
@@ -127,6 +127,7 @@ export type NoteSortOrder =
export type LineNumberMode = 'off' | 'absolute' | 'relative'
export type WhichKeyHintMode = 'timed' | 'sticky'
+export type PaletteNavKeys = 'ctrl-np' | 'ctrl-jk' | 'both'
export type CommandPaletteInitialMode = 'main' | 'vault'
const PREFS_KEY = 'zen:prefs:v2'
@@ -153,6 +154,7 @@ const VALID_SORTS: NoteSortOrder[] = [
]
const VALID_LINE_NUMBER_MODES: LineNumberMode[] = ['off', 'absolute', 'relative']
const VALID_WHICH_KEY_HINT_MODES: WhichKeyHintMode[] = ['timed', 'sticky']
+const VALID_PALETTE_NAV_KEYS: PaletteNavKeys[] = ['ctrl-np', 'ctrl-jk', 'both']
const VALID_VAULT_TEXT_SEARCH_BACKENDS: VaultTextSearchBackendPreference[] = [
'auto',
'builtin',
@@ -258,6 +260,8 @@ interface Prefs {
/** Key sequence that exits insert mode (maps to ), e.g. "jk".
* Empty disables it. */
vimInsertEscape: string
+ /** Which keys navigate palette results and editor autocompletions. */
+ paletteNavKeys: PaletteNavKeys
keymapOverrides: KeymapOverrides
/** When true, pressing the leader key shows the next available Vim-style actions. */
whichKeyHints: boolean
@@ -403,6 +407,7 @@ function normalizeKanbanColumnTitles(raw: unknown): Record {
const DEFAULT_PREFS: Prefs = {
vimMode: true,
vimInsertEscape: '',
+ paletteNavKeys: 'both',
keymapOverrides: {},
whichKeyHints: true,
whichKeyHintMode: 'timed',
@@ -480,6 +485,10 @@ function normalizePrefs(p: Partial): Prefs {
typeof p.vimInsertEscape === 'string'
? p.vimInsertEscape.trim().slice(0, 5)
: DEFAULT_PREFS.vimInsertEscape,
+ paletteNavKeys:
+ p.paletteNavKeys && VALID_PALETTE_NAV_KEYS.includes(p.paletteNavKeys)
+ ? p.paletteNavKeys
+ : DEFAULT_PREFS.paletteNavKeys,
keymapOverrides: normalizeKeymapOverrides(p.keymapOverrides),
whichKeyHints:
typeof p.whichKeyHints === 'boolean'
@@ -1021,65 +1030,11 @@ async function rewriteTagAcrossVault(
}
/** Snapshot prefs-shaped fields out of the live store. */
-function collectPrefs(s: {
- vimMode: boolean
- vimInsertEscape: string
- keymapOverrides: KeymapOverrides
- whichKeyHints: boolean
- whichKeyHintMode: WhichKeyHintMode
- whichKeyHintTimeoutMs: number
- vaultTextSearchBackend: VaultTextSearchBackendPreference
- ripgrepBinaryPath: string | null
- fzfBinaryPath: string | null
- livePreview: boolean
- tabsEnabled: boolean
- wrapTabs: boolean
- themeId: string
- themeFamily: ThemeFamily
- themeMode: ThemeMode
- editorFontSize: number
- editorLineHeight: number
- previewMaxWidth: number
- lineNumberMode: LineNumberMode
- interfaceFont: string | null
- textFont: string | null
- monoFont: string | null
- systemFolderLabels: SystemFolderLabels
- sidebarWidth: number
- noteListWidth: number
- noteSortOrder: NoteSortOrder
- groupByKind: boolean
- autoReveal: boolean
- unifiedSidebar: boolean
- darkSidebar: boolean
- showSidebarChevrons: boolean
- collapsedFolders: string[]
- pinnedRefPath: string | null
- pinnedRefVisible: boolean
- pinnedRefWidth: number
- panelWidths: PanelWidths
- pinnedRefMode: 'edit' | 'preview'
- quickNoteDateTitle: boolean
- quickNoteTitlePrefix: string | null
- wordWrap: boolean
- previewSmoothScroll: boolean
- editorMaxWidth: number
- pdfEmbedInEditMode: 'compact' | 'full'
- pinnedRefKind: 'note' | 'asset'
- noteRefs: Record
- contentAlign: 'center' | 'left'
- tagsCollapsed: boolean
- autoCalendarPanel: boolean
- calendarWeekStart: CalendarWeekStart
- calendarShowWeekNumbers: boolean
- tasksViewMode: TasksViewMode
- kanbanGroupBy: KanbanGroupBy
- kanbanColumnTitles: Record
- hasCompletedOnboarding: boolean
-}): Prefs {
+function collectPrefs(s: Prefs): Prefs {
return {
vimMode: s.vimMode,
vimInsertEscape: s.vimInsertEscape,
+ paletteNavKeys: s.paletteNavKeys,
keymapOverrides: s.keymapOverrides,
whichKeyHints: s.whichKeyHints,
whichKeyHintMode: s.whichKeyHintMode,
@@ -1429,6 +1384,8 @@ interface Store {
vimMode: boolean
/** Key sequence that exits insert mode (maps to ), e.g. "jk". Persisted. */
vimInsertEscape: string
+ /** Which keys navigate palette results and editor autocompletions. Persisted. */
+ paletteNavKeys: PaletteNavKeys
keymapOverrides: KeymapOverrides
whichKeyHints: boolean
whichKeyHintMode: WhichKeyHintMode
@@ -1706,6 +1663,7 @@ interface Store {
setFocusMode: (focus: boolean) => void
setVimMode: (on: boolean) => void
setVimInsertEscape: (sequence: string) => void
+ setPaletteNavKeys: (keys: PaletteNavKeys) => void
setKeymapBinding: (id: KeymapId, binding: string | null) => void
resetAllKeymaps: () => void
setWhichKeyHints: (on: boolean) => void
@@ -2631,6 +2589,7 @@ export const useStore = create((set, get) => {
zenRestoreState: null,
vimMode: loadPrefs().vimMode,
vimInsertEscape: loadPrefs().vimInsertEscape,
+ paletteNavKeys: loadPrefs().paletteNavKeys,
keymapOverrides: loadPrefs().keymapOverrides,
whichKeyHints: loadPrefs().whichKeyHints,
whichKeyHintMode: loadPrefs().whichKeyHintMode,
@@ -3959,6 +3918,11 @@ export const useStore = create((set, get) => {
set({ vimInsertEscape: sequence.trim().slice(0, 5) })
savePrefs(collectPrefs(get()))
},
+ setPaletteNavKeys: (keys) => {
+ if (!VALID_PALETTE_NAV_KEYS.includes(keys)) return
+ set({ paletteNavKeys: keys })
+ savePrefs(collectPrefs(get()))
+ },
setKeymapBinding: (id, binding) => {
set((s) => {
const nextOverrides = { ...s.keymapOverrides }
diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css
index 252aff54..7319b92e 100644
--- a/packages/app-core/src/styles/index.css
+++ b/packages/app-core/src/styles/index.css
@@ -3075,8 +3075,28 @@ textarea,
max-height: 320px !important;
}
-.cm-tooltip-autocomplete:has(.wikilink-cmd-option)::after {
- content: "Type | to change display text · Use /path/to/note for exact links";
+.cm-tooltip-autocomplete:has(.wikilink-cmd-option.palette-nav-ctrl-np)::after {
+ content: "Ctrl+N/P move · Type | to change display text · Use /path/to/note for exact links";
+ display: block;
+ padding: 10px 12px 8px;
+ border-top: 1px solid rgb(var(--z-bg-3) / 0.7);
+ font-size: 12px;
+ color: rgb(var(--z-fg-2));
+ text-align: center;
+}
+
+.cm-tooltip-autocomplete:has(.wikilink-cmd-option.palette-nav-ctrl-jk)::after {
+ content: "Ctrl+J/K move · Type | to change display text · Use /path/to/note for exact links";
+ display: block;
+ padding: 10px 12px 8px;
+ border-top: 1px solid rgb(var(--z-bg-3) / 0.7);
+ font-size: 12px;
+ color: rgb(var(--z-fg-2));
+ text-align: center;
+}
+
+.cm-tooltip-autocomplete:has(.wikilink-cmd-option.palette-nav-both)::after {
+ content: "Ctrl+N/P or J/K move · Type | to change display text · Use /path/to/note for exact links";
display: block;
padding: 10px 12px 8px;
border-top: 1px solid rgb(var(--z-bg-3) / 0.7);