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:
@@ -35,6 +35,9 @@ var ChatView = class extends import_obsidian.ItemView {
|
|||||||
this.pendingContext = [];
|
this.pendingContext = [];
|
||||||
this.explicitContext = [];
|
this.explicitContext = [];
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
// Mention autocomplete state
|
||||||
|
this.mentionSelectedIdx = 0;
|
||||||
|
this.mentionMatches = [];
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.renderComponent = new import_obsidian.Component();
|
this.renderComponent = new import_obsidian.Component();
|
||||||
}
|
}
|
||||||
@@ -93,6 +96,8 @@ var ChatView = class extends import_obsidian.ItemView {
|
|||||||
this.contextPreviewEl.style.display = "none";
|
this.contextPreviewEl.style.display = "none";
|
||||||
const inputArea = chatArea.createDiv("vc-input-area");
|
const inputArea = chatArea.createDiv("vc-input-area");
|
||||||
const inputWrapper = inputArea.createDiv("vc-input-wrapper");
|
const inputWrapper = inputArea.createDiv("vc-input-wrapper");
|
||||||
|
this.mentionDropdownEl = inputWrapper.createDiv("vc-mention-dropdown");
|
||||||
|
this.mentionDropdownEl.style.display = "none";
|
||||||
this.inputEl = inputWrapper.createEl("textarea", {
|
this.inputEl = inputWrapper.createEl("textarea", {
|
||||||
cls: "vc-input",
|
cls: "vc-input",
|
||||||
attr: { placeholder: "Frage stellen\u2026 (@ f\xFCr Notiz einf\xFCgen)" }
|
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.setText("Senden");
|
||||||
this.sendBtn.onclick = () => this.handleSend();
|
this.sendBtn.onclick = () => this.handleSend();
|
||||||
this.inputEl.addEventListener("keydown", (e) => {
|
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)) {
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.handleSend();
|
this.handleSend();
|
||||||
@@ -418,8 +445,71 @@ ${content}`;
|
|||||||
this.statusEl.style.display = text ? "block" : "none";
|
this.statusEl.style.display = text ? "block" : "none";
|
||||||
}
|
}
|
||||||
handleInputChange() {
|
handleInputChange() {
|
||||||
|
var _a;
|
||||||
this.inputEl.style.height = "auto";
|
this.inputEl.style.height = "auto";
|
||||||
this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 200) + "px";
|
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 ─────────────────────────────────────────────────────────
|
// ─── Persistence ─────────────────────────────────────────────────────────
|
||||||
loadThreads() {
|
loadThreads() {
|
||||||
|
|||||||
+103
@@ -37,6 +37,11 @@ export class ChatView extends ItemView {
|
|||||||
private contextPreviewEl!: HTMLElement;
|
private contextPreviewEl!: HTMLElement;
|
||||||
private sendBtn!: HTMLButtonElement;
|
private sendBtn!: HTMLButtonElement;
|
||||||
private statusEl!: HTMLElement;
|
private statusEl!: HTMLElement;
|
||||||
|
private mentionDropdownEl!: HTMLElement;
|
||||||
|
|
||||||
|
// Mention autocomplete state
|
||||||
|
private mentionSelectedIdx = 0;
|
||||||
|
private mentionMatches: string[] = [];
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, plugin: MemexChatPlugin) {
|
constructor(leaf: WorkspaceLeaf, plugin: MemexChatPlugin) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
@@ -123,6 +128,8 @@ export class ChatView extends ItemView {
|
|||||||
const inputArea = chatArea.createDiv("vc-input-area");
|
const inputArea = chatArea.createDiv("vc-input-area");
|
||||||
|
|
||||||
const inputWrapper = inputArea.createDiv("vc-input-wrapper");
|
const inputWrapper = inputArea.createDiv("vc-input-wrapper");
|
||||||
|
this.mentionDropdownEl = inputWrapper.createDiv("vc-mention-dropdown");
|
||||||
|
this.mentionDropdownEl.style.display = "none";
|
||||||
this.inputEl = inputWrapper.createEl("textarea", {
|
this.inputEl = inputWrapper.createEl("textarea", {
|
||||||
cls: "vc-input",
|
cls: "vc-input",
|
||||||
attr: { placeholder: "Frage stellen… (@ für Notiz einfügen)" },
|
attr: { placeholder: "Frage stellen… (@ für Notiz einfügen)" },
|
||||||
@@ -141,6 +148,28 @@ export class ChatView extends ItemView {
|
|||||||
|
|
||||||
// Key bindings
|
// Key bindings
|
||||||
this.inputEl.addEventListener("keydown", (e) => {
|
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)) {
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.handleSend();
|
this.handleSend();
|
||||||
@@ -514,6 +543,80 @@ export class ChatView extends ItemView {
|
|||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
this.inputEl.style.height = "auto";
|
this.inputEl.style.height = "auto";
|
||||||
this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 200) + "px";
|
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 ─────────────────────────────────────────────────────────
|
// ─── Persistence ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
+30
@@ -400,9 +400,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vc-input-wrapper {
|
.vc-input-wrapper {
|
||||||
|
position: relative;
|
||||||
margin-bottom: 6px;
|
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 {
|
.vc-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user