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
This commit is contained in:
Bohdan Triapitsyn
2026-05-24 17:21:11 +03:00
committed by GitHub
parent af25edd3f7
commit c4956af565
4 changed files with 695 additions and 59 deletions
+188 -58
View File
@@ -57,6 +57,7 @@ import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/l
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
@@ -68,12 +69,22 @@ import { wrapSystemReminder } from '@/lib/systemReminder';
import { getSyncMessages } from '@/sync/sync-refs';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
import { isSyntheticPart } from '@/lib/messages/synthetic';
import {
buildHighlightParts,
mentionRangesToHighlightRanges,
tokenizeMarkdown,
type HighlightRange,
type MentionRange,
} from './composerHighlight';
import { highlightFencedCode } from './composerCodeHighlight';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_MESSAGES: Message[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
// Single-line URL pasted over a selection becomes a markdown link.
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
@@ -1038,42 +1049,78 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const knownAgentNamesRef = React.useRef(knownAgentNames);
knownAgentNamesRef.current = knownAgentNames;
const hasInlineMentionForHighlight = React.useMemo(() => {
// Known slash-invocations (commands + skills + built-ins) used to highlight
// matching /tokens in the composer, the same way confirmed @files are.
const availableCommands = useCommandsStore((s) => s.commands);
const availableSkills = useSkillsStore((s) => s.skills);
const knownSlashNames = React.useMemo(() => {
const names = new Set<string>([
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review',
]);
for (const command of availableCommands) names.add(command.name.toLowerCase());
for (const skill of availableSkills) names.add(skill.name.toLowerCase());
return names;
}, [availableCommands, availableSkills]);
// /command and /skill spans (primary color). Only tokens that match a known
// command/skill name are highlighted — partial/unknown tokens stay plain.
const composerCommandRanges = React.useMemo<HighlightRange[]>(() => {
if (!message || !message.includes('/') || inputMode === 'shell' || knownSlashNames.size === 0) {
return [];
}
const ranges: HighlightRange[] = [];
const slashRegex = /(^|\s)\/([A-Za-z0-9][A-Za-z0-9_-]*)/g;
let match: RegExpExecArray | null;
while ((match = slashRegex.exec(message)) !== null) {
const name = match[2];
if (!knownSlashNames.has(name.toLowerCase())) {
continue;
}
const slashStart = match.index + match[1].length;
ranges.push({ start: slashStart, end: slashStart + 1 + name.length, style: 'mentionCommand' });
}
return ranges;
}, [inputMode, knownSlashNames, message]);
// Snippet triggers (#name / #alias). Highlighted like commands once the
// trigger matches a known snippet name or alias.
const availableSnippets = useSnippetsStore((s) => s.snippets);
const knownSnippetTriggers = React.useMemo(() => {
const triggers = new Set<string>();
for (const snippet of availableSnippets) {
triggers.add(snippet.name.toLowerCase());
for (const alias of snippet.aliases ?? []) triggers.add(alias.toLowerCase());
}
return triggers;
}, [availableSnippets]);
const composerSnippetRanges = React.useMemo<HighlightRange[]>(() => {
if (!message || !message.includes('#') || inputMode === 'shell' || knownSnippetTriggers.size === 0) {
return [];
}
const ranges: HighlightRange[] = [];
const snippetRegex = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
let match: RegExpExecArray | null;
while ((match = snippetRegex.exec(message)) !== null) {
const trigger = match[2];
if (!knownSnippetTriggers.has(trigger.toLowerCase())) {
continue;
}
const hashStart = match.index + match[1].length;
ranges.push({ start: hashStart, end: hashStart + 1 + trigger.length, style: 'mentionSnippet' });
}
return ranges;
}, [inputMode, knownSnippetTriggers, message]);
// @mention spans (file = blue, agent = green). Computed as character ranges
// so they can be merged with markdown highlight ranges in a single overlay.
const composerMentionRanges = React.useMemo<MentionRange[]>(() => {
if (!message || !message.includes('@') || inputMode === 'shell') {
return false;
return [];
}
const ranges: MentionRange[] = [];
const mentionRegex = /@([^\s]+)/g;
let match: RegExpExecArray | null;
while ((match = mentionRegex.exec(message)) !== null) {
const offset = match.index;
const charBefore = offset > 0 ? message[offset - 1] : null;
if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) {
continue;
}
const mentionPath = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
if (!mentionPath) {
continue;
}
if (knownAgentNames.has(mentionPath.toLowerCase())) {
return true;
}
if (isConfirmedFilePath(mentionPath)) {
return true;
}
}
return false;
}, [inputMode, message, knownAgentNames]);
const highlightedComposerContent = React.useMemo(() => {
if (!hasInlineMentionForHighlight) {
return null;
}
const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = [];
const mentionRegex = /@([^\s]+)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionRegex.exec(message)) !== null) {
const full = match[0];
const mention = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
@@ -1081,28 +1128,33 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const end = start + full.length;
const charBefore = start > 0 ? message[start - 1] : null;
const isBoundary = !charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore);
const isAgentMention = isBoundary && mention.length > 0 && knownAgentNames.has(mention.toLowerCase());
const isFileMention = isBoundary
&& mention.length > 0
&& !knownAgentNames.has(mention.toLowerCase())
&& isConfirmedFilePath(mention);
if (start > lastIndex) {
parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' });
if (!isBoundary || mention.length === 0) {
continue;
}
if (knownAgentNames.has(mention.toLowerCase())) {
ranges.push({ start, end, kind: 'agent' });
} else if (isConfirmedFilePath(mention)) {
ranges.push({ start, end, kind: 'file' });
}
parts.push({
text: full,
mentionKind: isFileMention ? 'file' : isAgentMention ? 'agent' : 'none',
});
lastIndex = end;
}
return ranges;
}, [inputMode, message, knownAgentNames]);
if (lastIndex < message.length) {
parts.push({ text: message.slice(lastIndex), mentionKind: 'none' });
// Combined source-mode highlight: markdown syntax + @mentions. Returns null
// when there's nothing to highlight so the overlay stays off for plain text.
const highlightedComposerContent = React.useMemo(() => {
if (!message || inputMode === 'shell') {
return null;
}
return parts;
}, [hasInlineMentionForHighlight, message, knownAgentNames]);
const ranges = [
...tokenizeMarkdown(message),
...highlightFencedCode(message),
...mentionRangesToHighlightRanges(composerMentionRanges),
...composerCommandRanges,
...composerSnippetRanges,
];
return buildHighlightParts(message, ranges);
}, [composerCommandRanges, composerSnippetRanges, composerMentionRanges, inputMode, message]);
const sanitizeAttachmentsForSend = React.useCallback(
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
@@ -2046,6 +2098,56 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
const canNavigateHistoryDown = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtEnd);
// Markdown-aware auto-pairing (source mode), normal input only.
if (inputMode === 'normal' && !isAnyAutocompleteOpen && !e.metaKey && !e.ctrlKey && !e.altKey) {
const ta = textareaRef.current;
const selStart = ta?.selectionStart ?? -1;
const selEnd = ta?.selectionEnd ?? -1;
if (ta && selStart >= 0) {
const applyEdit = (next: string, caretStart: number, caretEnd: number) => {
e.preventDefault();
setMessage(next);
requestAnimationFrame(() => {
const current = textareaRef.current;
if (current) {
current.selectionStart = caretStart;
current.selectionEnd = caretEnd;
}
adjustTextareaHeight();
});
updateAutocompleteState(next, caretEnd);
};
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
const WRAP_PAIRS: Record<string, [string, string]> = {
'`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'],
'(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'],
'"': ['"', '"'], "'": ["'", "'"],
};
if (selEnd > selStart && WRAP_PAIRS[e.key]) {
const [open, close] = WRAP_PAIRS[e.key];
const selected = message.slice(selStart, selEnd);
const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`;
applyEdit(next, selStart + open.length, selEnd + open.length);
return;
}
// Typing the third backtick at line start expands into a fenced
// code block with the caret on the empty middle line (Slack-like).
if (e.key === '`' && selStart === selEnd) {
const before = message.slice(0, selStart);
if (/(^|\n)``$/.test(before)) {
const after = message.slice(selEnd);
const next = `${before}\`\n\n\`\`\`${after}`;
const caret = before.length + 2; // after the completed ``` and first newline
applyEdit(next, caret, caret);
return;
}
}
}
}
if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) {
e.preventDefault();
if (historyIndex === -1) {
@@ -2603,6 +2705,40 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, [clearDropTextSuppression]);
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Pasting a URL over a selection wraps it as a markdown link:
// [selected text](pasted url).
if (inputMode === 'normal' && (currentSessionId || newSessionDraftOpen)) {
const ta = textareaRef.current;
const selStart = ta?.selectionStart ?? -1;
const selEnd = ta?.selectionEnd ?? -1;
if (ta && selEnd > selStart) {
const clipboardText = e.clipboardData.getData('text');
const url = clipboardText.trim();
const selected = message.slice(selStart, selEnd);
if (
PASTE_LINK_URL_PATTERN.test(url)
&& !/\s/.test(url)
&& selected.trim().length > 0
&& !selected.includes('](')
) {
e.preventDefault();
const next = `${message.slice(0, selStart)}[${selected}](${url})${message.slice(selEnd)}`;
const caret = selStart + 1 + selected.length + 2 + url.length + 1;
setMessage(next);
requestAnimationFrame(() => {
const current = textareaRef.current;
if (current) {
current.selectionStart = caret;
current.selectionEnd = caret;
}
adjustTextareaHeight();
});
updateAutocompleteState(next, caret);
return;
}
}
}
const fileMap = new Map<string, File>();
Array.from(e.clipboardData.files || []).forEach(file => {
@@ -2644,7 +2780,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.clipboardAttachFailed'));
}
}
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection, t]);
}, [addAttachedFile, adjustTextareaHeight, currentSessionId, inputMode, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
@@ -4052,13 +4188,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
{highlightedComposerContent.map((part, index) => (
<span
key={`${index}-${part.text.length}`}
className={
part.mentionKind === 'file'
? 'text-[var(--status-info)]'
: part.mentionKind === 'agent'
? 'text-[var(--status-success)]'
: 'text-foreground'
}
className={part.className}
>
{part.text}
</span>
@@ -0,0 +1,172 @@
/**
* Per-language syntax highlighting for fenced code blocks in the chat composer.
*
* Reuses the editor's CodeMirror language resolver and Lezer parsers so a
* ```bash / ```ts block in the composer is colored the same way it is in the
* file editor and in rendered messages. Output is fed into the composer's
* highlight overlay (see composerHighlight.ts), which sits behind a transparent
* <textarea>; the overlay may only change color / decoration / background, never
* glyph metrics, so every token class below is color-only.
*
* Only languages the resolver returns synchronously are highlighted. Unknown or
* lazily-loaded languages fall back to the uniform `codeFence` styling.
*/
import { Language } from '@codemirror/language';
import { highlightTree, tagHighlighter, tags as t } from '@lezer/highlight';
import { codeBlockLanguageResolver } from '@/lib/codemirror/languageByExtension';
import { isFenceClose, matchFenceOpen, type HighlightRange } from './composerHighlight';
const CODE_BG = 'bg-[var(--surface-subtle)]';
// Within a highlighted block: a neutral base (just above the uniform codeFence
// fill at 90) for untagged text, then per-token colors on top. Both stay below
// @mention / command highlights (100).
const NEUTRAL_BASE_PRIORITY = 91;
const SYNTAX_PRIORITY = 94;
type SyntaxKey =
| 'keyword' | 'string' | 'number' | 'comment'
| 'function' | 'type' | 'variable' | 'operator';
// highlightTree returns the space-joined classes of every styled node covering
// a range (and container tags like lists bleed onto their whole subtree). We
// emit single-word keys here and pick the most specific one per segment, so a
// marker tagged e.g. `strong meta` resolves to one coherent color.
const KEY_PRIORITY: Record<SyntaxKey, number> = {
keyword: 8, function: 7, type: 6, string: 5, number: 5, variable: 4, operator: 3, comment: 2,
};
const KEY_CLASS: Record<SyntaxKey, string> = {
keyword: `${CODE_BG} text-[var(--syntax-keyword)]`,
string: `${CODE_BG} text-[var(--syntax-string)]`,
number: `${CODE_BG} text-[var(--syntax-number)]`,
comment: `${CODE_BG} text-[var(--syntax-comment)]`,
function: `${CODE_BG} text-[var(--syntax-function)]`,
type: `${CODE_BG} text-[var(--syntax-type)]`,
variable: `${CODE_BG} text-[var(--syntax-variable)]`,
operator: `${CODE_BG} text-[var(--syntax-operator)]`,
};
const codeHighlighter = tagHighlighter([
{ tag: [t.comment, t.lineComment, t.blockComment, t.docComment, t.meta], class: 'comment' },
{
tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword, t.definitionKeyword, t.moduleKeyword, t.self, t.null],
class: 'keyword',
},
{ tag: [t.string, t.special(t.string), t.docString, t.character, t.regexp], class: 'string' },
{ tag: [t.number, t.integer, t.float, t.bool, t.atom], class: 'number' },
{ tag: [t.function(t.variableName), t.function(t.propertyName), t.macroName, t.standard(t.variableName)], class: 'function' },
{ tag: [t.typeName, t.className, t.namespace, t.tagName], class: 'type' },
{ tag: [t.variableName, t.propertyName, t.attributeName, t.labelName, t.definition(t.variableName)], class: 'variable' },
{ tag: [t.operator, t.punctuation, t.bracket, t.derefOperator, t.separator], class: 'operator' },
// Markup tags (markdown / html-ish code blocks). Container tags (list,
// contentSeparator) are intentionally omitted — they bleed onto plain text.
{ tag: [t.heading, t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6, t.strong], class: 'keyword' },
{ tag: [t.emphasis, t.quote], class: 'type' },
{ tag: [t.link, t.url], class: 'function' },
{ tag: [t.monospace], class: 'string' },
{ tag: [t.strikethrough], class: 'comment' },
]);
const pickSyntaxClass = (classes: string): string | null => {
let best: SyntaxKey | null = null;
for (const key of classes.split(' ')) {
const candidate = key as SyntaxKey;
if (KEY_PRIORITY[candidate] !== undefined && (best === null || KEY_PRIORITY[candidate] > KEY_PRIORITY[best])) {
best = candidate;
}
}
return best ? KEY_CLASS[best] : null;
};
// Cap per-block parsing so a giant pasted block can't stall the keystroke path;
// such blocks still get the neutral code base, just no per-token coloring.
const MAX_PARSE_LENGTH = 20_000;
const resolveSyncLanguage = (info: string): Language | null => {
if (!info) return null;
try {
const resolved = codeBlockLanguageResolver(info);
return resolved instanceof Language ? resolved : null;
} catch {
return null;
}
};
/**
* Produce syntax-token highlight ranges for every fenced code block in `text`
* whose language can be resolved synchronously. An unterminated block (still
* being typed) is highlighted up to the end of the text.
*/
export function highlightFencedCode(text: string): HighlightRange[] {
if (!text || (!text.includes('```') && !text.includes('~~~'))) return [];
const ranges: HighlightRange[] = [];
const lines = text.split('\n');
let offset = 0;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
offset += line.length + 1; // advance to the start of the next line
const fenceOpen = matchFenceOpen(line);
if (!fenceOpen) continue;
const lang = resolveSyncLanguage(fenceOpen.lang);
// Collect the body up to the matching closing fence (or end of text).
const bodyStart = offset;
const bodyLines: string[] = [];
let k = i + 1;
let cursor = offset;
for (; k < lines.length; k += 1) {
const bodyLine = lines[k];
if (isFenceClose(bodyLine, fenceOpen.marker)) {
cursor += bodyLine.length + 1;
break;
}
bodyLines.push(bodyLine);
cursor += bodyLine.length + 1;
}
// Resume the outer scan past the block we just consumed.
i = k;
offset = cursor;
if (!lang || bodyLines.length === 0) continue;
const code = bodyLines.join('\n');
if (!code.trim()) continue;
// For highlighted blocks the untagged base is neutral code text, so
// plain prose (e.g. inside a ```md block) is not tinted like a string.
// Unhighlighted blocks keep their distinct inline-code color instead.
ranges.push({
start: bodyStart,
end: bodyStart + code.length,
style: 'codeFence',
className: `${CODE_BG} text-[var(--syntax-foreground)]`,
priority: NEUTRAL_BASE_PRIORITY,
});
if (code.length > MAX_PARSE_LENGTH) continue;
try {
const tree = lang.parser.parse(code);
highlightTree(tree, codeHighlighter, (from, to, classes) => {
const className = pickSyntaxClass(classes);
if (!className) return;
ranges.push({
start: bodyStart + from,
end: bodyStart + to,
style: 'codeFence',
className,
priority: SYNTAX_PRIORITY,
});
});
} catch {
// Parsing failed — leave the block with its neutral base fill.
}
}
return ranges;
}
@@ -0,0 +1,329 @@
/**
* 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',
}));
}
@@ -17,7 +17,7 @@ import { shell } from '@codemirror/legacy-modes/mode/shell';
const shellLanguage = StreamLanguage.define(shell);
function codeBlockLanguageResolver(info: string): Language | LanguageDescription | null {
export function codeBlockLanguageResolver(info: string): Language | LanguageDescription | null {
const normalized = info.trim().toLowerCase();
switch (normalized) {
@@ -49,6 +49,11 @@ function codeBlockLanguageResolver(info: string): Language | LanguageDescription
case 'py':
case 'python':
return python().language;
case 'md':
case 'markdown':
case 'mdown':
case 'mkd':
return markdown().language;
case 'heex':
case 'eex':
case 'leex':