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>
|
||||
|
||||
Reference in New Issue
Block a user