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

View File

@@ -147,29 +147,36 @@ function tripleBacktickHandler() {
}
/**
* Keybinding: Ctrl/Cmd+E wraps selection in inline code backticks,
* or inserts a pair and places cursor between them.
* Wrap the selection with `before`/`after` markers (or insert an empty
* pair and place the cursor between them). Used for bold, italic, code.
*/
function inlineCodeKeymap() {
return keymap.of([{
key: 'Mod-e',
run: (view) => {
const { from, to } = view.state.selection.main;
if (from !== to) {
const text = view.state.doc.sliceString(from, to);
view.dispatch({
changes: { from, to, insert: '`' + text + '`' },
selection: { anchor: from + 1, head: to + 1 },
});
} else {
view.dispatch({
changes: { from, to: from, insert: '``' },
selection: { anchor: from + 1 },
});
}
return true;
},
}]);
function _wrapWith(view, before, after) {
const { from, to } = view.state.selection.main;
if (from !== to) {
const text = view.state.doc.sliceString(from, to);
view.dispatch({
changes: { from, to, insert: before + text + after },
selection: { anchor: from + before.length, head: to + before.length },
});
} else {
view.dispatch({
changes: { from, to: from, insert: before + after },
selection: { anchor: from + before.length },
});
}
return true;
}
/**
* Keybindings: Ctrl/Cmd+B (bold), Ctrl/Cmd+I (italic), Ctrl/Cmd+E (inline code).
* Wraps selection or inserts an empty pair with cursor between markers.
*/
function formattingKeymap() {
return keymap.of([
{ key: 'Mod-b', run: (view) => _wrapWith(view, '**', '**') },
{ key: 'Mod-i', run: (view) => _wrapWith(view, '_', '_') },
{ key: 'Mod-e', run: (view) => _wrapWith(view, '`', '`') },
]);
}
// ── Chat Input Factory ──────────────────────
@@ -193,6 +200,7 @@ export function chatInput(target, opts = {}) {
onChange = null,
maxHeight = 200,
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
extensions: extraExtensions = [],
} = opts;
// Build extensions
@@ -201,9 +209,9 @@ export function chatInput(target, opts = {}) {
markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
// Code block shortcuts
// Markdown shortcuts
tripleBacktickHandler(), // ``` → fenced code block
inlineCodeKeymap(), // Ctrl/Cmd+E → inline code
formattingKeymap(), // Ctrl/Cmd+B, I, E → bold, italic, code
// Code block visual decorations (WYSIWYG style)
codeBlockDeco,
@@ -275,6 +283,11 @@ export function chatInput(target, opts = {}) {
// Single-line display mode: wrap long lines
extensions.push(EditorView.lineWrapping);
// Extra extensions (e.g. @mention autocomplete)
if (extraExtensions.length) {
extensions.push(...extraExtensions);
}
const view = new EditorView({
state: EditorState.create({ doc: '', extensions }),
parent: target,

View File

@@ -31,6 +31,7 @@ import { emacs } from '@replit/codemirror-emacs';
import { codeEditor } from './code-editor.mjs';
import { chatInput } from './chat-input.mjs';
import { noteEditor } from './note-editor.mjs';
import { mentionExtension } from './mention.mjs';
// ── Expose on window ────────────────────────
@@ -54,6 +55,10 @@ window.CM = {
// CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter })
noteEditor,
// Extension: @mention autocomplete + decoration for chat input
// CM.mentionExtension({ completer: async (query) => [{ label, handle, type }] })
mentionExtension,
// Bundled language modes (for reference / dynamic use)
languages: {
javascript, json, markdown, sql, html, css, yaml, go, python, rust,

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;
}