Files
openchamber/packages/ui/src/components/chat/composerHighlight.ts
T
Bohdan Triapitsyn c4956af565 feat(chat): live markdown source-mode highlighting in composer (#1401)
* feat(chat): live markdown source-mode highlighting in composer

Highlight markdown syntax, fenced code blocks, and mention-style tokens
directly in the chat input via the existing transparent-textarea overlay
(color/decoration/background only, so caret alignment is preserved).

- Markdown source-mode: inline/fenced code, links, headings, blockquotes,
  list markers, with dimmed syntax punctuation
- Per-language syntax highlighting inside fenced blocks, reusing the editor's
  CodeMirror language resolver + Lezer (bash/js/ts/json/html/css/python/md);
  highlighted blocks use a neutral base, plain fences keep the code color
- Token highlighting on match: @file, @agent, /command, /skill, #snippet
- Auto-pairing: wrap selection with markers, triple-backtick expands to a
  fenced block; paste a URL over a selection to form a markdown link
- Add md/markdown to the shared code-block language resolver

* fix(chat): address composer highlight review

- Tilde (~~~) fenced blocks now get per-language syntax highlighting
- Share fence open/close detection between tokenizeMarkdown and
  highlightFencedCode so they agree on boundaries (fence length + format),
  fixing range bleed with 4-backtick fences and ```lang lines inside blocks
- Replace buildHighlightParts O(segments x ranges) scan with a sweep-line
  over an active set (verified equivalent vs the prior algorithm across 30k
  randomized cases, including overlaps and explicit class/priority)
- Cap per-block Lezer parsing at 20k chars; oversized blocks keep the neutral
  code base without per-token coloring
2026-05-24 17:21:11 +03:00

330 lines
12 KiB
TypeScript

/**
* Lightweight markdown tokenizer for the chat composer's highlight overlay.
*
* The composer renders a transparent <textarea> on top of a mirror <div>
* (see ChatInput.tsx). The div paints the colored text the user sees while the
* textarea owns the caret and selection. For the two layers to stay aligned the
* overlay may only use styles that DO NOT change glyph advance width:
* color, text-decoration and background. Font weight / style / family / size
* would shift the text and make the highlight drift from the caret, so they are
* intentionally avoided here.
*
* As a result we highlight the high-signal, low-false-positive markdown
* constructs (code, links, headings, blockquotes, list markers) and dim their
* syntax punctuation — a "source mode" look similar to GitHub's comment editor.
* Emphasis (*bold* / _italic_) is deliberately not colored: it can only be
* expressed through font weight (which we cannot use) and its delimiters clash
* with ordinary prose (`2 * 3`, `foo_bar`).
*/
export type HighlightStyle =
| 'marker'
| 'code'
| 'codeFence'
| 'link'
| 'linkUrl'
| 'heading'
| 'blockquote'
| 'listMarker';
export type MentionKind = 'file' | 'agent';
export interface HighlightRange {
start: number;
end: number;
style: HighlightStyle | 'mentionFile' | 'mentionAgent' | 'mentionCommand' | 'mentionSnippet';
/**
* Optional explicit class, used by syntax highlighting where the style is
* resolved dynamically (per language token) rather than from a fixed enum.
* When set it overrides STYLE_CLASS[style]. Must remain metric-safe
* (color / decoration / background only).
*/
className?: string;
/** Optional explicit priority; falls back to STYLE_PRIORITY[style]. */
priority?: number;
}
export interface MentionRange {
start: number;
end: number;
kind: MentionKind;
}
export interface HighlightPart {
text: string;
className: string;
}
type AnyStyle = HighlightRange['style'];
// Higher priority wins when ranges overlap on a given segment.
const STYLE_PRIORITY: Record<AnyStyle, number> = {
mentionFile: 100,
mentionAgent: 100,
mentionCommand: 100,
mentionSnippet: 100,
code: 90,
codeFence: 90,
link: 80,
linkUrl: 78,
heading: 70,
blockquote: 40,
listMarker: 35,
marker: 10,
};
// Color / decoration / background only — never anything that affects layout.
const STYLE_CLASS: Record<AnyStyle, string> = {
mentionFile: 'text-[var(--status-info)]',
mentionAgent: 'text-[var(--status-success)]',
mentionCommand: 'text-[var(--primary)]',
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
link: 'text-[var(--status-info)] underline',
linkUrl: 'text-muted-foreground',
heading: 'text-[var(--syntax-keyword)]',
blockquote: 'text-muted-foreground',
listMarker: 'text-[var(--syntax-keyword)]',
marker: 'text-muted-foreground',
};
const DEFAULT_CLASS = 'text-foreground';
/**
* Scan a single line (or the content portion of a block construct) for inline
* markdown spans and push their ranges. `base` is the absolute offset of
* `segment` within the full text.
*/
function scanInline(segment: string, base: number, out: HighlightRange[]): void {
let i = 0;
const n = segment.length;
while (i < n) {
const ch = segment[i];
// Inline code: a run of N backticks closed by an identical run.
if (ch === '`') {
const openRun = /^`+/.exec(segment.slice(i))?.[0] ?? '';
const closeIdx = segment.indexOf(openRun, i + openRun.length);
if (closeIdx !== -1) {
const end = closeIdx + openRun.length;
out.push({ start: base + i, end: base + end, style: 'code' });
i = end;
continue;
}
}
// Link: [text](url)
if (ch === '[') {
const m = /^\[([^\]\n]*)\]\(([^)\n]*)\)/.exec(segment.slice(i));
if (m) {
const p = base + i;
const textLen = m[1].length;
const urlLen = m[2].length;
const openMarkerEnd = p + 1;
const textEnd = openMarkerEnd + textLen;
const midMarkerEnd = textEnd + 2; // "]("
const urlEnd = midMarkerEnd + urlLen;
const closeEnd = urlEnd + 1; // ")"
out.push({ start: p, end: openMarkerEnd, style: 'marker' });
if (textLen > 0) out.push({ start: openMarkerEnd, end: textEnd, style: 'link' });
out.push({ start: textEnd, end: midMarkerEnd, style: 'marker' });
if (urlLen > 0) out.push({ start: midMarkerEnd, end: urlEnd, style: 'linkUrl' });
out.push({ start: urlEnd, end: closeEnd, style: 'marker' });
i += m[0].length;
continue;
}
}
i += 1;
}
}
/**
* Tokenize `text` into highlight ranges. Block constructs (fenced code,
* headings, blockquotes, list markers) are detected per line; inline spans are
* scanned within non-fenced lines.
*/
const FENCE_OPEN = /^(\s*)(`{3,}|~{3,})\s*(\S*)/;
export interface FenceOpen {
/** The full opening fence run, e.g. "```" or "~~~~". */
marker: string;
/** First info-string token (the language), or '' when absent. */
lang: string;
}
/** Recognize an opening code fence line (3+ backticks or tildes). */
export function matchFenceOpen(line: string): FenceOpen | null {
const match = FENCE_OPEN.exec(line);
return match ? { marker: match[2], lang: match[3] || '' } : null;
}
/**
* A closing fence: the same fence character, at least as long as the opening
* run, and nothing but whitespace after it — so a `` ```js `` line inside a
* block is treated as content, not a close. Shared with highlightFencedCode so
* both agree on fence boundaries.
*/
export function isFenceClose(line: string, openMarker: string): boolean {
return new RegExp(`^\\s*\\${openMarker[0]}{${openMarker.length},}\\s*$`).test(line);
}
export function tokenizeMarkdown(text: string): HighlightRange[] {
const ranges: HighlightRange[] = [];
if (!text) return ranges;
let offset = 0;
let inFence = false;
let openMarker = '';
const lines = text.split('\n');
for (let li = 0; li < lines.length; li += 1) {
const line = lines[li];
const lineStart = offset;
const lineEnd = lineStart + line.length;
// Advance past this line plus its trailing newline for the next iteration.
offset = lineEnd + 1;
if (inFence) {
ranges.push({ start: lineStart, end: lineEnd, style: 'codeFence' });
if (isFenceClose(line, openMarker)) {
inFence = false;
}
continue;
}
const fenceOpen = matchFenceOpen(line);
if (fenceOpen) {
inFence = true;
openMarker = fenceOpen.marker;
ranges.push({ start: lineStart, end: lineEnd, style: 'codeFence' });
continue;
}
const heading = /^(\s*)(#{1,6})(\s+)/.exec(line);
if (heading) {
const markerStart = lineStart + heading[1].length;
const markerEnd = markerStart + heading[2].length;
ranges.push({ start: markerStart, end: markerEnd, style: 'marker' });
const contentStart = markerEnd + heading[3].length;
if (lineEnd > contentStart) {
ranges.push({ start: contentStart, end: lineEnd, style: 'heading' });
scanInline(line.slice(contentStart - lineStart), contentStart, ranges);
}
continue;
}
const quote = /^(\s*)(>+)(\s?)/.exec(line);
if (quote) {
const markerStart = lineStart + quote[1].length;
const markerEnd = markerStart + quote[2].length;
ranges.push({ start: markerStart, end: markerEnd, style: 'marker' });
const contentStart = markerEnd + quote[3].length;
if (lineEnd > contentStart) {
ranges.push({ start: contentStart, end: lineEnd, style: 'blockquote' });
scanInline(line.slice(contentStart - lineStart), contentStart, ranges);
}
continue;
}
const list = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)/.exec(line);
if (list) {
const markerStart = lineStart + list[1].length;
const markerEnd = markerStart + list[2].length;
ranges.push({ start: markerStart, end: markerEnd, style: 'listMarker' });
const contentStart = markerEnd + list[3].length;
scanInline(line.slice(contentStart - lineStart), contentStart, ranges);
continue;
}
scanInline(line, lineStart, ranges);
}
return ranges;
}
/**
* Split `text` into styled parts from a set of (possibly overlapping) ranges.
* Each output part carries a single className; adjacent parts that share a
* className are coalesced. Returns null when there is nothing to highlight so
* callers can skip the overlay entirely for plain text.
*/
export function buildHighlightParts(
text: string,
ranges: HighlightRange[],
): HighlightPart[] | null {
if (!text || ranges.length === 0) return null;
const len = text.length;
const bounds = new Set<number>([0, len]);
for (const range of ranges) {
if (range.start > 0 && range.start < len) bounds.add(range.start);
if (range.end > 0 && range.end < len) bounds.add(range.end);
}
const sorted = [...bounds].sort((a, b) => a - b);
// Sweep the boundaries keeping an "active" set of ranges covering the
// current segment, so each segment costs O(active) instead of O(ranges).
// (Boundaries include every range start/end, so any active range that has
// started and not ended necessarily spans the whole segment.)
// Keep original index so ties (equal priority) resolve to the earliest
// range in input order — matching the prior straight O(n) scan.
const byStart = ranges
.map((range, index) => ({ range, index }))
.filter((item) => item.range.end > item.range.start)
.sort((a, b) => a.range.start - b.range.start);
const parts: HighlightPart[] = [];
const active: Array<{ range: HighlightRange; index: number }> = [];
let nextRange = 0;
for (let i = 0; i < sorted.length - 1; i += 1) {
const segStart = sorted[i];
const segEnd = sorted[i + 1];
if (segEnd <= segStart) continue;
while (nextRange < byStart.length && byStart[nextRange].range.start <= segStart) {
active.push(byStart[nextRange]);
nextRange += 1;
}
for (let a = active.length - 1; a >= 0; a -= 1) {
if (active[a].range.end <= segStart) active.splice(a, 1);
}
let bestRange: HighlightRange | null = null;
let bestPriority = -1;
let bestIndex = Infinity;
for (const { range, index } of active) {
const priority = range.priority ?? STYLE_PRIORITY[range.style];
if (priority > bestPriority || (priority === bestPriority && index < bestIndex)) {
bestPriority = priority;
bestIndex = index;
bestRange = range;
}
}
const className = bestRange
? (bestRange.className ?? STYLE_CLASS[bestRange.style])
: DEFAULT_CLASS;
const segText = text.slice(segStart, segEnd);
const last = parts[parts.length - 1];
if (last && last.className === className) {
last.text += segText;
} else {
parts.push({ text: segText, className });
}
}
return parts.length > 0 ? parts : null;
}
export function mentionRangesToHighlightRanges(mentions: MentionRange[]): HighlightRange[] {
return mentions.map((mention) => ({
start: mention.start,
end: mention.end,
style: mention.kind === 'file' ? 'mentionFile' : 'mentionAgent',
}));
}