Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

156
src/editor/mention.mjs Normal file
View File

@@ -0,0 +1,156 @@
// ==========================================
// Chat Switchboard — CM6 @Mention Extension
// ==========================================
// Provides:
// 1. Autocomplete triggered by @ for personas/users
// 2. ViewPlugin that decorates @handle as styled marks
// 3. Theme for mention styling in the editor
//
// Modeled on wikilink.mjs — same pattern adapted for
// @mention tokens instead of [[wikilink]] syntax.
//
// Usage:
// import { mentionExtension } from './mention.mjs';
// const ext = mentionExtension({
// completer: async (query) => [{ label, handle, type }],
// });
// ==========================================
import {
ViewPlugin, Decoration, EditorView,
} from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { autocompletion } from '@codemirror/autocomplete';
// ── Mention Regex ───────────────────────────
// Matches @handle tokens: @ preceded by start-of-string or whitespace,
// followed by one or more word chars or hyphens.
const MENTION_RE = /(?:^|(?<=\s))@([\w-]+)/g;
// ── ViewPlugin: Decorate @mentions ──────────
function mentionDecoPlugin() {
return ViewPlugin.fromClass(class {
decorations;
constructor(view) {
this.decorations = this.build(view);
}
update(update) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.build(update.view);
}
}
build(view) {
const builder = new RangeSetBuilder();
const doc = view.state.doc;
const text = doc.toString();
const sel = view.state.selection.main;
let match;
MENTION_RE.lastIndex = 0;
while ((match = MENTION_RE.exec(text)) !== null) {
// match[0] includes any leading whitespace captured by lookbehind,
// but lookbehind is zero-width so match.index points to @
const atIdx = text.indexOf('@', match.index);
const from = atIdx;
const to = from + 1 + match[1].length; // @handle
// Don't decorate if cursor is inside the mention (let user edit)
if (sel.from >= from && sel.from <= to) continue;
builder.add(from, to, Decoration.mark({ class: 'cm-mention' }));
}
return builder.finish();
}
}, {
decorations: (v) => v.decorations,
});
}
// ── Autocomplete: @ trigger ─────────────────
function mentionAutocomplete(completer) {
return autocompletion({
override: [
async (ctx) => {
// Look for @ before the cursor (at line start or after whitespace)
const line = ctx.state.doc.lineAt(ctx.pos);
const textBefore = ctx.state.doc.sliceString(line.from, ctx.pos);
const triggerMatch = textBefore.match(/(?:^|\s)@([\w-]*)$/);
if (!triggerMatch) return null;
const query = triggerMatch[1];
// from = position of the first char after @
const from = ctx.pos - query.length;
if (!completer) return null;
try {
const results = await completer(query);
if (!results || results.length === 0) return null;
return {
from,
options: results.map(r => ({
label: r.handle,
displayLabel: r.label,
detail: r.type === 'all' ? 'all users' : r.type,
apply: (view, completion, from, to) => {
// Insert handle + trailing space
const insert = completion.label + ' ';
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
},
})),
};
} catch (e) {
console.warn('[mention] autocomplete error:', e);
return null;
}
},
],
activateOnTyping: true,
});
}
// ── Theme: Mention Styles ───────────────────
const mentionTheme = EditorView.baseTheme({
'.cm-mention': {
color: 'var(--accent, #6366f1)',
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.1)',
borderRadius: '3px',
padding: '0 2px',
fontWeight: '500',
},
});
// ── Public: Combined Extension ──────────────
/**
* Create the @mention CM6 extension bundle.
*
* @param {Object} opts
* @param {Function} opts.completer - async (query) => [{ label, handle, type }]
* @returns {Extension[]} Array of CM6 extensions
*/
export function mentionExtension(opts = {}) {
const extensions = [
mentionDecoPlugin(),
mentionTheme,
];
if (opts.completer) {
extensions.push(mentionAutocomplete(opts.completer));
}
return extensions;
}