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:
committed by
GitHub
parent
af25edd3f7
commit
c4956af565
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user