diff --git a/src/editors/automerge/automergeEditor.css b/src/editors/automerge/automergeEditor.css
index 2cc65a7..bc208bb 100644
--- a/src/editors/automerge/automergeEditor.css
+++ b/src/editors/automerge/automergeEditor.css
@@ -169,6 +169,13 @@
font-size: 14px;
max-height: 16em;
padding: 2px 0;
+ /* See the footer block below: `order` fixes the rendered position, and
+ `height: auto` + `min-height: 0` beat CM's `height: 100%` so the list
+ shrinks (and scrolls) rather than pushing the footer out. */
+ order: 1;
+ flex: 1 1 auto;
+ height: auto;
+ min-height: 0;
}
/* (0,4,2) beats CM's (0,3,2). */
@@ -211,6 +218,58 @@
color: var(--semantic-primary-fg);
}
+/*
+ * The hint strip along the bottom of the popup.
+ *
+ * `order` is load-bearing, not cosmetic: CodeMirror rebuilds the option list on
+ * every update by appending a fresh `
`, which lands AFTER our footer in DOM
+ * order. Laying the popup out as a flex column and ordering both children
+ * explicitly makes the rendered order independent of the DOM order.
+ *
+ * The list needs `min-height: 0` and `height: auto` alongside: CM's base theme
+ * sets `height: 100%`, and when vertical space is tight it pins an explicit
+ * pixel height on the popup — either would otherwise push the footer out of
+ * view instead of scrolling the list.
+ */
+.automerge-editor-container .cm-editor .cm-tooltip.cm-tooltip-autocomplete {
+ display: flex;
+ flex-direction: column;
+}
+
+.automerge-editor-container
+ .cm-editor
+ .cm-tooltip.cm-tooltip-autocomplete
+ > .cm-wikilink-completion-footer {
+ order: 2;
+ flex: 0 0 auto;
+ display: flex;
+ gap: 0.9em;
+ align-items: center;
+ padding: 4px 8px;
+ border-top: 1px solid var(--semantic-border);
+ font-size: 11px;
+ color: var(--semantic-fg);
+ opacity: 0.55;
+ white-space: nowrap;
+ user-select: none;
+ /* Inert: keeps the footer out of the tooltip's mousedown handler and out of
+ the close-on-blur focusout path. It is help text, not a control. */
+ pointer-events: none;
+}
+
+.automerge-editor-container
+ .cm-editor
+ .cm-tooltip.cm-tooltip-autocomplete
+ > .cm-wikilink-completion-footer
+ kbd {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.95em;
+ border: 1px solid var(--semantic-border);
+ border-radius: 3px;
+ padding: 0 3px;
+ margin-right: 0.25em;
+}
+
/* Heading level badge (`H2`) leading a heading option. Fixed width so the
labels beside them line up regardless of level. */
.automerge-editor-container .cm-editor .cm-wikilink-level {
diff --git a/src/editors/automerge/livePreview/completionFooter.ts b/src/editors/automerge/livePreview/completionFooter.ts
new file mode 100644
index 0000000..370dca4
--- /dev/null
+++ b/src/editors/automerge/livePreview/completionFooter.ts
@@ -0,0 +1,61 @@
+/**
+ * A hint strip along the bottom of the `[[` completion popup, teaching the two
+ * modifiers — `#` for a heading and `|` for display text — which are otherwise
+ * entirely undiscoverable.
+ *
+ * @codemirror/autocomplete has no footer API. `Completion.section` renders a
+ * header ABOVE a group and an empty section is not rendered at all, and none of
+ * `tooltipClass` / `optionClass` / `addToOptions` / `positionInfo` can place a
+ * node after the list. So we append one to the popup's root ourselves.
+ *
+ * Two things make that safe. CodeMirror rebuilds the list on every update with
+ * `list.remove(); dom.appendChild(createListBox(...))` — it removes only the
+ * list, so our node survives, but it ends up BEFORE the new `` in DOM
+ * order. The CSS therefore lays the popup out as a flex column and orders the
+ * footer explicitly, so DOM order cannot affect what is rendered. And an
+ * updateListener is the right hook because `EditorView.update` runs
+ * `updatePlugins` — which builds the tooltip DOM synchronously — before the
+ * listener loop and before the measure pass, so we always see the current
+ * popup and our footer is included when it is measured.
+ */
+import { type Extension } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { completionStatus } from '@codemirror/autocomplete';
+
+const FOOTER_CLASS = 'cm-wikilink-completion-footer';
+
+/** `[key, meaning]`, rendered left to right. */
+const HINTS: readonly (readonly [string, string])[] = [
+ ['#', 'heading'],
+ ['|', 'display text'],
+];
+
+function buildFooter(): HTMLElement {
+ const footer = document.createElement('div');
+ footer.className = FOOTER_CLASS;
+ for (const [key, meaning] of HINTS) {
+ const hint = document.createElement('span');
+ const kbd = document.createElement('kbd');
+ kbd.textContent = key;
+ hint.append(kbd, ` ${meaning}`);
+ footer.append(hint);
+ }
+ return footer;
+}
+
+/**
+ * Note this attaches to every completion popup in the editor. That is correct
+ * today because `wikiLinkCompletionSource` is the only source registered — if a
+ * second one is ever added, this needs to discriminate rather than silently
+ * telling the user about `#` and `|` in an unrelated popup.
+ */
+export const wikiLinkCompletionFooter: Extension = EditorView.updateListener.of(
+ (update) => {
+ if (completionStatus(update.state) == null) return;
+ const tooltip = update.view.dom.querySelector(
+ '.cm-tooltip-autocomplete',
+ );
+ if (!tooltip || tooltip.querySelector(`:scope > .${FOOTER_CLASS}`)) return;
+ tooltip.append(buildFooter());
+ },
+);
diff --git a/src/editors/automerge/livePreview/wikiLinkComplete.ts b/src/editors/automerge/livePreview/wikiLinkComplete.ts
index bb3a8db..af11345 100644
--- a/src/editors/automerge/livePreview/wikiLinkComplete.ts
+++ b/src/editors/automerge/livePreview/wikiLinkComplete.ts
@@ -26,6 +26,7 @@ import {
import { wikiLinkHeadingsFacet, wikiLinkVaultFacet } from './wikiLinkVault';
import { headingsIn } from './headingReveal';
+import { wikiLinkCompletionFooter } from './completionFooter';
import { normalizeAnchor, type DocHeading } from '../../../wikilinks/headings';
/** `[[` followed by anything that is still a plain target. */
@@ -226,4 +227,5 @@ export const wikiLinkCompletion: Extension = [
...completionKeymap,
]),
),
+ wikiLinkCompletionFooter,
];