diff --git a/deno.lock b/deno.lock index 6811a38e8..308164e67 100644 --- a/deno.lock +++ b/deno.lock @@ -67,6 +67,7 @@ "npm:@arizeai/openinference-semantic-conventions@^1.1.0": "1.1.0", "npm:@arizeai/openinference-vercel@^2.0.1": "2.3.1_@opentelemetry+api@1.9.0", "npm:@babel/standalone@^7.28.2": "7.28.2", + "npm:@codemirror/autocomplete@^6.15.0": "6.18.6", "npm:@codemirror/lang-css@^6.3.1": "6.3.1", "npm:@codemirror/lang-html@^6.4.9": "6.4.9", "npm:@codemirror/lang-javascript@^6.2.2": "6.2.4", @@ -2153,6 +2154,7 @@ }, "packages/ui": { "dependencies": [ + "npm:@codemirror/autocomplete@^6.15.0", "npm:@codemirror/lang-css@^6.3.1", "npm:@codemirror/lang-html@^6.4.9", "npm:@codemirror/lang-javascript@^6.2.2", diff --git a/packages/patterns/chatbot-note.tsx b/packages/patterns/chatbot-note.tsx index 77018fcd7..ec06bc8b0 100644 --- a/packages/patterns/chatbot-note.tsx +++ b/packages/patterns/chatbot-note.tsx @@ -57,7 +57,7 @@ export const Note = recipe( @@ -170,11 +170,7 @@ export default recipe( /> - - - - - + {ifElse( diff --git a/packages/ui/deno.json b/packages/ui/deno.json index cc8d0e81e..f1035bd8a 100644 --- a/packages/ui/deno.json +++ b/packages/ui/deno.json @@ -22,6 +22,7 @@ "@codemirror/lang-css": "npm:@codemirror/lang-css@^6.3.1", "@codemirror/lang-html": "npm:@codemirror/lang-html@^6.4.9", "@codemirror/lang-json": "npm:@codemirror/lang-json@^6.0.1", - "@codemirror/theme-one-dark": "npm:@codemirror/theme-one-dark@^6.1.2" + "@codemirror/theme-one-dark": "npm:@codemirror/theme-one-dark@^6.1.2", + "@codemirror/autocomplete": "npm:@codemirror/autocomplete@^6.15.0" } } diff --git a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts index 9e5026604..cb156835c 100644 --- a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts +++ b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts @@ -2,7 +2,7 @@ import { html, PropertyValues } from "lit"; import { BaseElement } from "../../core/base-element.ts"; import { styles } from "./styles.ts"; import { basicSetup } from "codemirror"; -import { EditorView, placeholder } from "@codemirror/view"; +import { EditorView, keymap, placeholder } from "@codemirror/view"; import { Compartment, EditorState, Extension } from "@codemirror/state"; import { LanguageSupport } from "@codemirror/language"; import { javascript as createJavaScript } from "@codemirror/lang-javascript"; @@ -11,9 +11,23 @@ import { css as createCss } from "@codemirror/lang-css"; import { html as createHtml } from "@codemirror/lang-html"; import { json as createJson } from "@codemirror/lang-json"; import { oneDark } from "@codemirror/theme-one-dark"; -import { type Cell } from "@commontools/runner"; +import { + autocompletion, + Completion, + CompletionContext, + CompletionResult, +} from "@codemirror/autocomplete"; +import { + Decoration, + DecorationSet, + ViewPlugin, + ViewUpdate, + WidgetType, +} from "@codemirror/view"; +import { type Cell, getEntityId, NAME } from "@commontools/runner"; import { type InputTimingOptions } from "../../core/input-timing-controller.ts"; import { createStringCellController } from "../../core/cell-controller.ts"; +import { Charm } from "@commontools/charm"; /** * Supported MIME types for syntax highlighting @@ -66,10 +80,12 @@ const getLangExtFromMimeType = (mime: MimeType) => { * @attr {string} placeholder - Placeholder text when empty * @attr {string} timingStrategy - Input timing strategy: "immediate" | "debounce" | "throttle" | "blur" * @attr {number} timingDelay - Delay in milliseconds for debounce/throttle (default: 500) + * @attr {Array} mentionable - Array of mentionable items with Charm structure for backlink autocomplete * * @fires ct-change - Fired when content changes with detail: { value, oldValue, language } * @fires ct-focus - Fired on focus * @fires ct-blur - Fired on blur + * @fires backlink-click - Fired when a backlink is clicked with Cmd/Ctrl+Enter with detail: { text, charm } * * @example * @@ -85,6 +101,7 @@ export class CTCodeEditor extends BaseElement { placeholder: { type: String }, timingStrategy: { type: String }, timingDelay: { type: Number }, + mentionable: { type: Array }, }; declare value: Cell | string; @@ -94,6 +111,7 @@ export class CTCodeEditor extends BaseElement { declare placeholder: string; declare timingStrategy: InputTimingOptions["strategy"]; declare timingDelay: number; + declare mentionable: Cell; private _editorView: EditorView | undefined; private _lang = new Compartment(); @@ -124,6 +142,218 @@ export class CTCodeEditor extends BaseElement { this.timingDelay = 500; } + /** + * Create a backlink completion source for [[backlinks]] + */ + private createBacklinkCompletionSource() { + return (context: CompletionContext): CompletionResult | null => { + // Look for incomplete backlinks: [[ followed by optional text + const backlink = context.matchBefore(/\[\[([^\]]*)?/); + + if (!backlink) { + return null; + } + + // Check what comes after the cursor + const afterCursor = context.state.doc.sliceString( + context.pos, + context.pos + 2, + ); + + // Allow completion inside existing backlinks - we'll replace the content between [[ and ]] + const query = backlink.text.slice(2); // Remove [[ prefix + + const mentionable = this.getFilteredMentionable(query); + + if (mentionable.length === 0) return null; + + const options: Completion[] = mentionable.map((charm) => { + const charmIdObj = getEntityId(charm); + const charmId = charmIdObj?.["/"] || ""; + const charmName = charm[NAME] || ""; + const insertText = `${charmName} (${charmId})`; + return { + label: charmName, + apply: afterCursor === "]]" ? insertText : insertText + "]]", + type: "text", + info: "Backlink to " + charmName, + }; + }); + + return { + from: backlink.from + 2, // Start after [[ + to: afterCursor === "]]" ? context.pos : undefined, + options, + validFor: /^[^\]]*$/, + }; + }; + } + + /** + * Get filtered mentionable items based on query + */ + private getFilteredMentionable(query: string): Charm[] { + if (!this.mentionable) { + return []; + } + + const mentionableData = this.mentionable.getAsQueryResult(); + + if (!mentionableData || mentionableData.length === 0) { + return []; + } + + const queryLower = query.toLowerCase(); + const matches: Charm[] = []; + + for (let i = 0; i < mentionableData.length; i++) { + const mention = this.mentionable.key(i).getAsQueryResult(); + if (mention && mention[NAME]?.toLowerCase()?.includes(queryLower)) { + matches.push(mention); + } + } + + return matches; + } + + /** + * Handle backlink clicks with Cmd/Ctrl+Click + */ + private createBacklinkClickHandler() { + return EditorView.domEventHandlers({ + click: (event, view) => { + if (event.ctrlKey || event.metaKey) { + return this.handleBacklinkActivation(view, event); + } + return false; + }, + }); + } + + /** + * Handle backlink activation (Cmd/Ctrl+Click on a backlink) + */ + private handleBacklinkActivation( + view: EditorView, + event?: MouseEvent, + ): boolean { + const state = view.state; + const pos = state.selection.main.head; + const doc = state.doc; + + // Find backlinks around cursor position + const lineStart = doc.lineAt(pos).from; + const lineEnd = doc.lineAt(pos).to; + const lineText = doc.sliceString(lineStart, lineEnd); + + // Find all [[...]] patterns in the line + const backlinkRegex = /\[\[([^\]]+)\]\]/g; + let match; + + while ((match = backlinkRegex.exec(lineText)) !== null) { + const matchStart = lineStart + match.index; + const matchEnd = matchStart + match[0].length; + + // Check if cursor is within this backlink + if (pos >= matchStart && pos <= matchEnd) { + const backlinkText = match[1]; // This is "Name (id)" format + // Extract ID from "Name (id)" format + const idMatch = backlinkText.match(/\(([^)]+)\)$/); + const backlinkId = idMatch ? idMatch[1] : backlinkText; + const charm = this.findCharmById(backlinkId); + + if (charm) { + this.emit("backlink-click", { + id: backlinkId, + text: backlinkText, + charm: charm, + }); + return true; + } + } + } + + return false; + } + + /** + * Find a charm by ID in the mentionable list + */ + private findCharmById(id: string): Charm | null { + if (!this.mentionable) return null; + + const mentionableData = this.mentionable.getAsQueryResult(); + if (!mentionableData) return null; + + for (let i = 0; i < mentionableData.length; i++) { + const charm = this.mentionable.key(i).getAsQueryResult(); + if (charm) { + const charmIdObj = getEntityId(charm); + const charmId = charmIdObj?.["/"] || ""; + if (charmId === id) { + return charm; + } + } + } + + return null; + } + + /** + * Create a plugin to decorate backlinks with special styling + */ + private createBacklinkDecorationPlugin() { + const backlinkMark = Decoration.mark({ class: "cm-backlink" }); + + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.getBacklinkDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.getBacklinkDecorations(update.view); + } + } + + getBacklinkDecorations(view: EditorView) { + const decorations = []; + const doc = view.state.doc; + const backlinkRegex = /\[\[([^\]]+)\]\]/g; + + for (const { from, to } of view.visibleRanges) { + for (let pos = from; pos <= to;) { + const line = doc.lineAt(pos); + const text = line.text; + let match; + + backlinkRegex.lastIndex = 0; // Reset regex + while ((match = backlinkRegex.exec(text)) !== null) { + const start = line.from + match.index; + const end = start + match[0].length; + + // Only decorate if within visible range + if (start >= from && end <= to) { + decorations.push(backlinkMark.range(start, end)); + } + } + + pos = line.to + 1; + } + } + + return Decoration.set(decorations); + } + }, + { + decorations: (v) => v.decorations, + }, + ); + } + private getValue(): string { return this._cellController.getValue(); } @@ -279,6 +509,16 @@ export class CTCodeEditor extends BaseElement { return false; }, }), + // Add backlink click handler for Cmd/Ctrl+Click + this.createBacklinkClickHandler(), + // Add backlink decoration plugin to visually style [[backlinks]] + this.createBacklinkDecorationPlugin(), + // Add autocompletion with backlink support + autocompletion({ + override: [this.createBacklinkCompletionSource()], + activateOnTyping: true, + closeOnBlur: true, + }), ]; // Add placeholder extension if specified diff --git a/packages/ui/src/v2/components/ct-code-editor/styles.ts b/packages/ui/src/v2/components/ct-code-editor/styles.ts index fc3ce23b3..394bb3c15 100644 --- a/packages/ui/src/v2/components/ct-code-editor/styles.ts +++ b/packages/ui/src/v2/components/ct-code-editor/styles.ts @@ -49,4 +49,22 @@ export const styles = css` .cm-editor { border-radius: var(--radius, 0.375rem); } + + /* Backlink styling - make [[backlinks]] visually distinct */ + .cm-content .cm-line { + position: relative; + } + + /* Style for backlinks - we'll use a highlight mark */ + .cm-backlink { + background-color: var(--ring-alpha, hsla(212, 100%, 47%, 0.1)); + border-radius: 0.25rem; + padding: 0.125rem 0.25rem; + cursor: pointer; + transition: background-color 0.2s; + } + + .cm-backlink:hover { + background-color: var(--ring-alpha, hsla(212, 100%, 47%, 0.2)); + } `;