Add @mention autocomplete for vault notes

Typing @ followed by 2+ characters triggers a live dropdown that
filters all vault notes by basename. Navigation via ↑/↓ arrows,
confirm with Enter or Tab, dismiss with Escape or mouse click.
Selecting inserts [[NoteName]] at the cursor position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-04 21:29:02 +01:00
parent 0e6287c265
commit 4941b9184e
3 changed files with 223 additions and 0 deletions
+90
View File
@@ -35,6 +35,9 @@ var ChatView = class extends import_obsidian.ItemView {
this.pendingContext = [];
this.explicitContext = [];
this.isLoading = false;
// Mention autocomplete state
this.mentionSelectedIdx = 0;
this.mentionMatches = [];
this.plugin = plugin;
this.renderComponent = new import_obsidian.Component();
}
@@ -93,6 +96,8 @@ var ChatView = class extends import_obsidian.ItemView {
this.contextPreviewEl.style.display = "none";
const inputArea = chatArea.createDiv("vc-input-area");
const inputWrapper = inputArea.createDiv("vc-input-wrapper");
this.mentionDropdownEl = inputWrapper.createDiv("vc-mention-dropdown");
this.mentionDropdownEl.style.display = "none";
this.inputEl = inputWrapper.createEl("textarea", {
cls: "vc-input",
attr: { placeholder: "Frage stellen\u2026 (@ f\xFCr Notiz einf\xFCgen)" }
@@ -106,6 +111,28 @@ var ChatView = class extends import_obsidian.ItemView {
this.sendBtn.setText("Senden");
this.sendBtn.onclick = () => this.handleSend();
this.inputEl.addEventListener("keydown", (e) => {
if (this.mentionDropdownEl.style.display !== "none") {
if (e.key === "ArrowDown") {
e.preventDefault();
this.moveMentionSelection(1);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
this.moveMentionSelection(-1);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
this.confirmMentionSelection();
return;
}
if (e.key === "Escape") {
e.preventDefault();
this.hideMentionDropdown();
return;
}
}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.handleSend();
@@ -418,8 +445,71 @@ ${content}`;
this.statusEl.style.display = text ? "block" : "none";
}
handleInputChange() {
var _a;
this.inputEl.style.height = "auto";
this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 200) + "px";
const cursor = (_a = this.inputEl.selectionStart) != null ? _a : 0;
const textBefore = this.inputEl.value.slice(0, cursor);
const match = textBefore.match(/@([^@\n[\]]{2,})$/);
if (match) {
this.updateMentionDropdown(match[1]);
} else {
this.hideMentionDropdown();
}
}
updateMentionDropdown(query) {
const lower = query.toLowerCase();
this.mentionMatches = this.app.vault.getMarkdownFiles().map((f) => f.basename).filter((name) => name.toLowerCase().includes(lower)).slice(0, 8);
if (this.mentionMatches.length === 0) {
this.hideMentionDropdown();
return;
}
this.mentionSelectedIdx = 0;
this.renderMentionDropdown();
this.mentionDropdownEl.style.display = "block";
}
renderMentionDropdown() {
this.mentionDropdownEl.empty();
this.mentionMatches.forEach((name, i) => {
const item = this.mentionDropdownEl.createDiv(
i === this.mentionSelectedIdx ? "vc-mention-item vc-mention-item--active" : "vc-mention-item"
);
item.setText(name);
item.addEventListener("mousedown", (e) => {
e.preventDefault();
this.insertMention(name);
});
});
}
moveMentionSelection(dir) {
this.mentionSelectedIdx = (this.mentionSelectedIdx + dir + this.mentionMatches.length) % this.mentionMatches.length;
this.renderMentionDropdown();
}
confirmMentionSelection() {
const name = this.mentionMatches[this.mentionSelectedIdx];
if (name)
this.insertMention(name);
}
insertMention(basename) {
var _a;
const cursor = (_a = this.inputEl.selectionStart) != null ? _a : 0;
const text = this.inputEl.value;
const textBefore = text.slice(0, cursor);
const match = textBefore.match(/@([^@\n[\]]{2,})$/);
if (!match)
return;
const start = cursor - match[0].length;
const replacement = `[[${basename}]]`;
this.inputEl.value = text.slice(0, start) + replacement + text.slice(cursor);
const newCursor = start + replacement.length;
this.inputEl.setSelectionRange(newCursor, newCursor);
this.hideMentionDropdown();
}
hideMentionDropdown() {
this.mentionDropdownEl.style.display = "none";
this.mentionDropdownEl.empty();
this.mentionMatches = [];
this.mentionSelectedIdx = 0;
}
// ─── Persistence ─────────────────────────────────────────────────────────
loadThreads() {
+103
View File
@@ -37,6 +37,11 @@ export class ChatView extends ItemView {
private contextPreviewEl!: HTMLElement;
private sendBtn!: HTMLButtonElement;
private statusEl!: HTMLElement;
private mentionDropdownEl!: HTMLElement;
// Mention autocomplete state
private mentionSelectedIdx = 0;
private mentionMatches: string[] = [];
constructor(leaf: WorkspaceLeaf, plugin: MemexChatPlugin) {
super(leaf);
@@ -123,6 +128,8 @@ export class ChatView extends ItemView {
const inputArea = chatArea.createDiv("vc-input-area");
const inputWrapper = inputArea.createDiv("vc-input-wrapper");
this.mentionDropdownEl = inputWrapper.createDiv("vc-mention-dropdown");
this.mentionDropdownEl.style.display = "none";
this.inputEl = inputWrapper.createEl("textarea", {
cls: "vc-input",
attr: { placeholder: "Frage stellen… (@ für Notiz einfügen)" },
@@ -141,6 +148,28 @@ export class ChatView extends ItemView {
// Key bindings
this.inputEl.addEventListener("keydown", (e) => {
if (this.mentionDropdownEl.style.display !== "none") {
if (e.key === "ArrowDown") {
e.preventDefault();
this.moveMentionSelection(1);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
this.moveMentionSelection(-1);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
this.confirmMentionSelection();
return;
}
if (e.key === "Escape") {
e.preventDefault();
this.hideMentionDropdown();
return;
}
}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.handleSend();
@@ -514,6 +543,80 @@ export class ChatView extends ItemView {
// Auto-resize textarea
this.inputEl.style.height = "auto";
this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 200) + "px";
// @mention autocomplete
const cursor = this.inputEl.selectionStart ?? 0;
const textBefore = this.inputEl.value.slice(0, cursor);
const match = textBefore.match(/@([^@\n[\]]{2,})$/);
if (match) {
this.updateMentionDropdown(match[1]);
} else {
this.hideMentionDropdown();
}
}
private updateMentionDropdown(query: string): void {
const lower = query.toLowerCase();
this.mentionMatches = this.app.vault
.getMarkdownFiles()
.map((f) => f.basename)
.filter((name) => name.toLowerCase().includes(lower))
.slice(0, 8);
if (this.mentionMatches.length === 0) {
this.hideMentionDropdown();
return;
}
this.mentionSelectedIdx = 0;
this.renderMentionDropdown();
this.mentionDropdownEl.style.display = "block";
}
private renderMentionDropdown(): void {
this.mentionDropdownEl.empty();
this.mentionMatches.forEach((name, i) => {
const item = this.mentionDropdownEl.createDiv(
i === this.mentionSelectedIdx ? "vc-mention-item vc-mention-item--active" : "vc-mention-item"
);
item.setText(name);
item.addEventListener("mousedown", (e) => {
e.preventDefault();
this.insertMention(name);
});
});
}
private moveMentionSelection(dir: 1 | -1): void {
this.mentionSelectedIdx =
(this.mentionSelectedIdx + dir + this.mentionMatches.length) % this.mentionMatches.length;
this.renderMentionDropdown();
}
private confirmMentionSelection(): void {
const name = this.mentionMatches[this.mentionSelectedIdx];
if (name) this.insertMention(name);
}
private insertMention(basename: string): void {
const cursor = this.inputEl.selectionStart ?? 0;
const text = this.inputEl.value;
const textBefore = text.slice(0, cursor);
const match = textBefore.match(/@([^@\n[\]]{2,})$/);
if (!match) return;
const start = cursor - match[0].length;
const replacement = `[[${basename}]]`;
this.inputEl.value = text.slice(0, start) + replacement + text.slice(cursor);
const newCursor = start + replacement.length;
this.inputEl.setSelectionRange(newCursor, newCursor);
this.hideMentionDropdown();
}
private hideMentionDropdown(): void {
this.mentionDropdownEl.style.display = "none";
this.mentionDropdownEl.empty();
this.mentionMatches = [];
this.mentionSelectedIdx = 0;
}
// ─── Persistence ─────────────────────────────────────────────────────────
+30
View File
@@ -400,9 +400,39 @@
}
.vc-input-wrapper {
position: relative;
margin-bottom: 6px;
}
.vc-mention-dropdown {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
right: 0;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 100;
}
.vc-mention-item {
padding: 7px 12px;
font-size: 13px;
cursor: pointer;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vc-mention-item:hover,
.vc-mention-item--active {
background: var(--background-modifier-hover);
color: var(--text-accent);
}
.vc-input {
width: 100%;
resize: none;