// // Copyright 2026 DXOS.org // import { type Extension, type Range } from '@codemirror/state'; import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view'; import { escapeRegExpSource } from '../../../util/index.ts'; export type XmlBlockDecorationOptions = { /** * Tag name to match (e.g. `'prompt'`). */ tag: string; /** * Class added via `Decoration.line` to each line that intersects the element's content * range. Use to style the bubble container (e.g. flex alignment, vertical margin). */ lineClass?: string; /** * Additional class added only to the first line of the content range. Use to round the top * of a block bubble and pad its top edge (single line receives both first and last classes). */ firstLineClass?: string; /** * Additional class added only to the last line of the content range. Use to round the * bottom of a block bubble and pad its bottom edge. */ lastLineClass?: string; /** * Class added via `Decoration.mark` covering the inner content (between the open and * close tags). Use to style the bubble surface (background, padding, rounding). */ contentClass?: string; /** * When true, the open and close tag delimiters are hidden via `Decoration.replace` * with no widget, so the rendered text is the inner content only. */ hideTags?: boolean; }; /** * Scans the doc for `…` blocks and decorates them without replacing them with a * widget — the source text remains in the document and can still be matched by other extensions * (e.g. `xmlFormatting`). Use this for "bubble"-style styling of XML blocks (chat prompts, * callouts, etc.) where the inner content should stay editable/copyable rather than living inside * a CodeMirror widget. * * Matching is done by direct text scan rather than a full-document XML parse: the transcript is * mostly markdown, and a single stray `<` anywhere upstream (code, `a < b`, an unclosed streaming * widget tag) corrupts a whole-document XML parse — which previously caused a well-formed prompt's * `` to go unrecognised, extending the bubble over the rest of the conversation. An * unterminated open tag (no matching ``, e.g. mid-stream) is left undecorated rather than * painting to the end of the document. */ // TODO(burdon): Change to array to support multiple tags. export const xmlBlockDecoration = ({ tag, lineClass, firstLineClass, lastLineClass, contentClass, hideTags, }: XmlBlockDecorationOptions): Extension => { const contentDecoration = contentClass ? Decoration.mark({ class: contentClass }) : undefined; const hideDecoration = hideTags ? Decoration.replace({}) : undefined; // Match an opening `` or `` (not self-closing); the paired `` is located // by scanning forward so unrelated markup between blocks cannot break matching. `tag` is escaped // so a name containing regex metacharacters (e.g. `foo.bar`) matches literally. const escapedTag = escapeRegExpSource(tag); const openTagRegExp = new RegExp(`<${escapedTag}(?:\\s[^>]*)?>`, 'g'); const closeTagStr = ``; const buildDecorations = (view: EditorView): DecorationSet => { const docLength = view.state.doc.length; const text = view.state.sliceDoc(0, docLength); if (!text.includes(`<${tag}`)) { return Decoration.none; } const ranges: Range[] = []; openTagRegExp.lastIndex = 0; let match: RegExpExecArray | null; while ((match = openTagRegExp.exec(text))) { const openFrom = match.index; const openTo = openFrom + match[0].length; const closeFrom = text.indexOf(closeTagStr, openTo); // Skip unterminated blocks: extending decoration to the document end would paint the bubble // over everything that follows (e.g. the assistant response streaming in after the prompt). if (closeFrom === -1) { break; } const closeTo = closeFrom + closeTagStr.length; const contentFrom = openTo; const contentTo = closeFrom; if (hideDecoration) { ranges.push(hideDecoration.range(openFrom, openTo)); ranges.push(hideDecoration.range(closeFrom, closeTo)); } if (contentDecoration && contentFrom < contentTo) { ranges.push(contentDecoration.range(contentFrom, contentTo)); } if (lineClass || firstLineClass || lastLineClass) { // Collect every line that intersects the content range so that the first and last // can be styled distinctly (e.g. rounding the top/bottom of a single block bubble). const lines = []; let pos = contentFrom; while (pos < contentTo && pos <= docLength) { const line = view.state.doc.lineAt(pos); // Only lines that intersect visible content: a hidden close tag on its own line would // otherwise get an empty rounded bubble line after the tag is replaced. if (line.to > contentFrom && line.from < contentTo) { lines.push(line); } if (line.to >= contentTo) { break; } pos = line.to + 1; } lines.forEach((line, index) => { const cls = [lineClass, index === 0 && firstLineClass, index === lines.length - 1 && lastLineClass] .filter(Boolean) .join(' '); if (cls) { ranges.push(Decoration.line({ class: cls }).range(line.from)); } }); } // Resume scanning past the close tag so content is never re-matched. openTagRegExp.lastIndex = closeTo; } return Decoration.set(ranges, true); }; return ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = buildDecorations(view); } update(update: ViewUpdate) { if (update.docChanged) { this.decorations = buildDecorations(update.view); } } }, { decorations: (instance) => instance.decorations, }, ); };