diff --git a/src/renderer/features/agents/mentions/agents-mentions-editor.tsx b/src/renderer/features/agents/mentions/agents-mentions-editor.tsx index a45844bf..71fa4ac3 100644 --- a/src/renderer/features/agents/mentions/agents-mentions-editor.tsx +++ b/src/renderer/features/agents/mentions/agents-mentions-editor.tsx @@ -11,6 +11,8 @@ import { memo, } from "react" import { createFileIconElement } from "./agents-file-mention" +import { hasCodeBlocks, parseCodeBlocks } from "../utils/parse-user-code-blocks" +import { highlightCode, onThemeChange } from "../../../lib/themes/shiki-theme-loader" // Threshold for skipping expensive trigger detection (characters) // Should be >= MAX_PASTE_LENGTH from paste-text.ts to avoid processing large pasted content @@ -109,6 +111,126 @@ function createMentionNode(option: FileMentionOption): HTMLSpanElement { return span } +// Get the current code theme from DOM state (for use outside React context) +function getCodeThemeFromDOM(): string { + const isDark = document.documentElement.classList.contains("dark") + return isDark ? "github-dark" : "github-light" +} + +// Apply syntax highlighting to a code block element asynchronously. +// Subscribes to theme changes so editor code blocks re-highlight on dark/light toggle. +// Auto-unsubscribes when the element is removed from the DOM. +function applyHighlighting(codeEl: HTMLElement, code: string, language: string): void { + const doHighlight = () => { + const themeId = getCodeThemeFromDOM() + highlightCode(code, language || "plaintext", themeId) + .then((html) => { + if (codeEl.isConnected) { + // Only use innerHTML if it looks like Shiki output (contains tokens). + // If highlightCode's regex fallback returned raw text, use textContent to prevent XSS. + if (html.includes(" { + // Keep plain text fallback + }) + } + + doHighlight() + + const unsubscribe = onThemeChange(() => { + if (codeEl.isConnected) { + doHighlight() + } else { + unsubscribe() + } + }) +} + +// Create styled code block element (non-editable block within the contenteditable) +export function createCodeBlockNode(language: string, code: string): HTMLDivElement { + const wrapper = document.createElement("div") + wrapper.setAttribute("contenteditable", "false") + wrapper.setAttribute("data-code-block", "true") + wrapper.setAttribute("data-code-language", language) + wrapper.className = + "relative my-1 rounded-[10px] bg-muted/50 overflow-hidden select-none" + + // Header with language label and remove button + const header = document.createElement("div") + header.className = + "flex items-center justify-between px-3 pt-2 pb-0 text-xs text-muted-foreground" + + const langLabel = document.createElement("span") + langLabel.textContent = language || "code" + langLabel.className = "font-medium" + header.appendChild(langLabel) + + const removeBtn = document.createElement("button") + removeBtn.type = "button" + removeBtn.setAttribute("aria-label", "Remove code block") + removeBtn.className = + "flex items-center justify-center w-4 h-4 rounded text-muted-foreground hover:text-foreground transition-colors" + removeBtn.innerHTML = + '' + removeBtn.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + const block = (e.currentTarget as HTMLElement).closest("[data-code-block]") + if (block) { + const editor = block.closest('[contenteditable="true"]') + block.remove() + if (editor) { + // Dispatch input event so the editor updates its state + editor.dispatchEvent(new Event("input", { bubbles: true })) + } + } + }) + header.appendChild(removeBtn) + wrapper.appendChild(header) + + // Code content + const pre = document.createElement("pre") + pre.className = "m-0 px-3 py-2 overflow-x-auto max-h-[150px] overflow-y-auto" + pre.style.fontFamily = + "SFMono-Regular, Menlo, Consolas, 'PT Mono', 'Liberation Mono', Courier, monospace" + pre.style.fontSize = "13px" + pre.style.lineHeight = "1.5" + pre.style.tabSize = "2" + pre.style.whiteSpace = "pre" + + const codeEl = document.createElement("code") + codeEl.textContent = code + pre.appendChild(codeEl) + wrapper.appendChild(pre) + + // Apply syntax highlighting asynchronously (plain text shows first, then highlights) + applyHighlighting(codeEl, code, language) + + return wrapper +} + +// Advance a TreeWalker past the subtree rooted at `el`. +// Returns the next node to visit, or null if traversal is complete. +function skipSubtree(walker: TreeWalker, el: Element): Node | null { + const next: Node | null = el.nextSibling + if (next) { + walker.currentNode = next + return next + } + let parent: Node | null = el.parentNode + while (parent && !parent.nextSibling) parent = parent.parentNode + if (parent && parent.nextSibling) { + walker.currentNode = parent.nextSibling + return parent.nextSibling + } + return null +} + // Serialize DOM to text with @[id] tokens function serializeContent(root: HTMLElement): string { let result = "" @@ -130,53 +252,34 @@ function serializeContent(root: HTMLElement): string { node = walker.nextNode() continue } - // Handle
elements (some browsers wrap lines in divs) - if (el.tagName === "DIV" && el !== root) { - // Add newline before div content (if not at start) - if (result.length > 0 && !result.endsWith("\n")) { - result += "\n" - } - node = walker.nextNode() + // Handle code block elements (must be checked before generic DIV handler + // since the code block wrapper is also a
) + if (el.hasAttribute("data-code-block")) { + const lang = el.getAttribute("data-code-language") || "" + const code = el.querySelector("code")?.textContent || "" + result += "```" + lang + "\n" + code + "\n```" + node = skipSubtree(walker, el) continue } // Handle ultrathink styled nodes if (el.hasAttribute("data-ultrathink")) { result += el.textContent || "" - // Skip subtree - let next: Node | null = el.nextSibling - if (next) { - walker.currentNode = next - node = next - continue - } - let parent: Node | null = el.parentNode - while (parent && !parent.nextSibling) parent = parent.parentNode - if (parent && parent.nextSibling) { - walker.currentNode = parent.nextSibling - node = parent.nextSibling - } else { - node = null - } + node = skipSubtree(walker, el) continue } if (el.hasAttribute("data-mention-id")) { const id = el.getAttribute("data-mention-id") || "" result += `@[${id}]` - // Skip subtree - let next: Node | null = el.nextSibling - if (next) { - walker.currentNode = next - node = next - continue - } - let parent: Node | null = el.parentNode - while (parent && !parent.nextSibling) parent = parent.parentNode - if (parent && parent.nextSibling) { - walker.currentNode = parent.nextSibling - node = parent.nextSibling - } else { - node = null + node = skipSubtree(walker, el) + continue + } + // Handle
elements (some browsers wrap lines in divs) + if (el.tagName === "DIV" && el !== root) { + // Add newline before div content (if not at start) + if (result.length > 0 && !result.endsWith("\n")) { + result += "\n" } + node = walker.nextNode() continue } node = walker.nextNode() @@ -184,25 +287,20 @@ function serializeContent(root: HTMLElement): string { return result } -// Build DOM from serialized text -function buildContentFromSerialized( +// Build DOM nodes for a text segment (handles @[...] mentions) +function buildTextSegment( root: HTMLElement, - serialized: string, + text: string, resolveMention?: (id: string) => FileMentionOption | null, ) { - // Clear safely - while (root.firstChild) { - root.removeChild(root.firstChild) - } - const regex = /@\[([^\]]+)\]/g let lastIndex = 0 let match: RegExpExecArray | null - while ((match = regex.exec(serialized)) !== null) { + while ((match = regex.exec(text)) !== null) { // Text before mention if (match.index > lastIndex) { - appendText(root, serialized.slice(lastIndex, match.index)) + appendText(root, text.slice(lastIndex, match.index)) } const id = match[1] // Try to resolve mention @@ -254,11 +352,41 @@ function buildContentFromSerialized( } // Remaining text - if (lastIndex < serialized.length) { - appendText(root, serialized.slice(lastIndex)) + if (lastIndex < text.length) { + appendText(root, text.slice(lastIndex)) } } +// Build DOM from serialized text +// Two-pass: first split by fenced code blocks, then process text segments for mentions +function buildContentFromSerialized( + root: HTMLElement, + serialized: string, + resolveMention?: (id: string) => FileMentionOption | null, +) { + // Clear safely + while (root.firstChild) { + root.removeChild(root.firstChild) + } + + // Check for fenced code blocks first + if (hasCodeBlocks(serialized)) { + const segments = parseCodeBlocks(serialized) + for (const segment of segments) { + if (segment.type === "code") { + root.appendChild(createCodeBlockNode(segment.language, segment.content)) + } else { + // Process text segment for mentions + buildTextSegment(root, segment.content, resolveMention) + } + } + return + } + + // No code blocks — process entire text for mentions (fast path) + buildTextSegment(root, serialized, resolveMention) +} + // Combined tree walk result - computes everything in ONE pass instead of 3 interface TreeWalkResult { serialized: string @@ -395,10 +523,25 @@ function walkTreeOnce(root: HTMLElement, range: Range | null): TreeWalkResult { continue } - // Element node - check for ultrathink or mention + // Element node - check for code block, ultrathink, or mention if (node.nodeType === Node.ELEMENT_NODE) { const el = node as HTMLElement + // Handle code block elements + if (el.hasAttribute("data-code-block")) { + const lang = el.getAttribute("data-code-language") || "" + const code = el.querySelector("code")?.textContent || "" + const token = "```" + lang + "\n" + code + "\n```" + serialized += token + if (!reachedCursor) { + textBeforeCursor += token + } + + // Skip code block subtree + node = skipSubtree(walker, el) + continue + } + // Handle ultrathink styled nodes if (el.hasAttribute("data-ultrathink")) { const text = el.textContent || "" @@ -407,21 +550,7 @@ function walkTreeOnce(root: HTMLElement, range: Range | null): TreeWalkResult { textBeforeCursor += text } - // Skip ultrathink subtree - let next: Node | null = el.nextSibling - if (next) { - walker.currentNode = next - node = next - continue - } - let parent: Node | null = el.parentNode - while (parent && !parent.nextSibling) parent = parent.parentNode - if (parent && parent.nextSibling) { - walker.currentNode = parent.nextSibling - node = parent.nextSibling - continue - } - node = null + node = skipSubtree(walker, el) continue } @@ -433,21 +562,7 @@ function walkTreeOnce(root: HTMLElement, range: Range | null): TreeWalkResult { textBeforeCursor += mentionToken } - // Skip mention subtree - let next: Node | null = el.nextSibling - if (next) { - walker.currentNode = next - node = next - continue - } - let parent: Node | null = el.parentNode - while (parent && !parent.nextSibling) parent = parent.parentNode - if (parent && parent.nextSibling) { - walker.currentNode = parent.nextSibling - node = parent.nextSibling - continue - } - node = null + node = skipSubtree(walker, el) continue } } @@ -552,10 +667,10 @@ export const AgentsMentionsEditor = memo( cursorOffset += node.textContent?.length || 0 } else if (node.nodeType === Node.ELEMENT_NODE) { const el = node as HTMLElement - // Mention nodes count as their serialized length for consistency - if (el.hasAttribute("data-mention-id")) { - cursorOffset += 1 // Count mention as single unit - // Skip children of mention node - move walker to next sibling + // Mention and code block nodes count as single units for cursor offset + if (el.hasAttribute("data-mention-id") || el.hasAttribute("data-code-block")) { + cursorOffset += 1 // Count as single unit + // Skip children - move walker to next sibling const nextSibling = walker.nextSibling() if (nextSibling) { node = nextSibling @@ -640,10 +755,10 @@ export const AgentsMentionsEditor = memo( currentOffset += nodeLength } else if (node.nodeType === Node.ELEMENT_NODE) { const el = node as HTMLElement - if (el.hasAttribute("data-mention-id")) { - // Mention counts as 1 unit + if (el.hasAttribute("data-mention-id") || el.hasAttribute("data-code-block")) { + // Mention and code block count as 1 unit if (currentOffset + 1 >= offset) { - // Place cursor after mention + // Place cursor after element const range = document.createRange() range.setStartAfter(el) range.collapse(true) @@ -652,7 +767,7 @@ export const AgentsMentionsEditor = memo( return } currentOffset += 1 - // Skip to next sibling (don't traverse inside mention) + // Skip to next sibling (don't traverse inside) const nextSibling = walker.nextSibling() if (nextSibling) { node = nextSibling @@ -808,6 +923,9 @@ export const AgentsMentionsEditor = memo( // Trigger detection timeout ref for cleanup const triggerDetectionTimeout = useRef(null) + // Code block detection debounce timer and re-entrancy guard + const codeBlockDetectionTimer = useRef | null>(null) + const isRebuildingCodeBlocks = useRef(false) // Handle input - UNCONTROLLED: no onChange, just @ and / trigger detection const handleInput = useCallback(() => { @@ -964,7 +1082,43 @@ export const AgentsMentionsEditor = memo( cancelAnimationFrame(triggerDetectionTimeout.current) } triggerDetectionTimeout.current = requestAnimationFrame(runTriggerDetection) - }, [onContentChange, onTrigger, onCloseTrigger, onSlashTrigger, onCloseSlashTrigger, debouncedSaveUndoState]) + + // Debounced code block detection: check if user has typed a complete fenced code block + // Only runs after 300ms of no input, and only if ``` is present in text content + // Skipped if we're in the middle of a DOM rebuild (re-entrancy guard) + if (codeBlockDetectionTimer.current) { + clearTimeout(codeBlockDetectionTimer.current) + } + if (content.includes("```") && !isRebuildingCodeBlocks.current) { + codeBlockDetectionTimer.current = setTimeout(() => { + if (!editorRef.current || isRebuildingCodeBlocks.current) return + // Get serialized text and check for complete fenced code blocks + const serialized = serializeContent(editorRef.current) + if (hasCodeBlocks(serialized)) { + isRebuildingCodeBlocks.current = true + try { + // Save undo state before transforming + immediateSaveUndoState() + // Rebuild DOM with code blocks rendered as styled elements + buildContentFromSerialized(editorRef.current, serialized, resolveMention) + // Move cursor to end + const sel = window.getSelection() + if (sel) { + sel.selectAllChildren(editorRef.current) + sel.collapseToEnd() + } + // Update content state + const newContent = editorRef.current.textContent || "" + const newHasContent = !!newContent + setHasContent(newHasContent) + onContentChange?.(newHasContent) + } finally { + isRebuildingCodeBlocks.current = false + } + } + }, 300) + } + }, [onContentChange, onTrigger, onCloseTrigger, onSlashTrigger, onCloseSlashTrigger, debouncedSaveUndoState, immediateSaveUndoState, resolveMention]) // Cleanup on unmount useEffect(() => { @@ -972,6 +1126,9 @@ export const AgentsMentionsEditor = memo( if (triggerDetectionTimeout.current) { cancelAnimationFrame(triggerDetectionTimeout.current) } + if (codeBlockDetectionTimer.current) { + clearTimeout(codeBlockDetectionTimer.current) + } } }, []) diff --git a/src/renderer/features/agents/mentions/render-file-mentions.tsx b/src/renderer/features/agents/mentions/render-file-mentions.tsx index 0379d23d..bd73a750 100644 --- a/src/renderer/features/agents/mentions/render-file-mentions.tsx +++ b/src/renderer/features/agents/mentions/render-file-mentions.tsx @@ -1,9 +1,11 @@ "use client" -import { createContext, useContext, useMemo } from "react" +import { createContext, useContext, useMemo, useState, useEffect } from "react" import { getFileIconByExtension } from "./agents-file-mention" import { FilesIcon, SkillIcon, CustomAgentIcon, OriginalMCPIcon } from "../../../components/ui/icons" import { MENTION_PREFIXES } from "./agents-mentions-editor" +import { hasCodeBlocks, parseCodeBlocks, looksLikeCode } from "../utils/parse-user-code-blocks" +import { highlightCode, onThemeChange } from "../../../lib/themes/shiki-theme-loader" import { HoverCard, HoverCardContent, @@ -495,13 +497,8 @@ export function extractTextMentions(text: string): { cleanedText = cleanedText.replace(mentionStr, "") } - // Clean up extra whitespace but preserve newlines - // Only collapse multiple spaces (not newlines) into one space - // and trim leading/trailing whitespace from each line + // Clean up extra whitespace left by mention removal while preserving indentation cleanedText = cleanedText - .split("\n") - .map(line => line.trim()) - .join("\n") .replace(/\n{3,}/g, "\n\n") // Collapse 3+ newlines to 2 .trim() @@ -609,3 +606,108 @@ export function TextMentionBlocks({ mentions }: { mentions: ParsedMention[] }) {
) } + +/** + * Syntax-highlighted code block for user messages (read-only, no remove button). + */ +function UserCodeBlock({ code, language }: { code: string; language: string }) { + const [highlighted, setHighlighted] = useState(null) + + useEffect(() => { + let cancelled = false + + const highlight = () => { + const isDark = document.documentElement.classList.contains("dark") + const themeId = isDark ? "github-dark" : "github-light" + + highlightCode(code, language || "plaintext", themeId) + .then((html) => { + if (!cancelled && html.includes(" { + // Keep plain text fallback + }) + } + + highlight() + const unsubscribe = onThemeChange(highlight) + + return () => { + cancelled = true + unsubscribe() + } + }, [code, language]) + + return ( +
+
+ {language || "code"} +
+
+        {highlighted ? (
+          
+        ) : (
+          {code}
+        )}
+      
+
+ ) +} + +/** + * Render user message text with fenced code blocks highlighted and + * remaining text passed through RenderFileMentions. + * + * Use this in place of when the text may contain + * triple-backtick fenced code blocks or unfenced code. + */ +export function RenderUserContent({ + text, + className, +}: { + text: string + className?: string +}) { + const segments = useMemo(() => { + // First: check for fenced code blocks (```lang\ncode\n```) + if (hasCodeBlocks(text)) { + return parseCodeBlocks(text) + } + // Fallback: check if entire text looks like code (unfenced) + const detection = looksLikeCode(text) + if (detection.isCode && detection.language) { + return [{ type: "code" as const, content: text, language: detection.language }] + } + return null + }, [text]) + + // Fast path: no code blocks, fall through to existing renderer + if (!segments) { + return + } + + // Use div wrapper instead of span because UserCodeBlock renders block-level elements + return ( +
+ {segments.map((seg, i) => + seg.type === "code" ? ( + + ) : ( + + ), + )} +
+ ) +} diff --git a/src/renderer/features/agents/ui/agent-user-message-bubble.tsx b/src/renderer/features/agents/ui/agent-user-message-bubble.tsx index 0361988f..0b1b4c8f 100644 --- a/src/renderer/features/agents/ui/agent-user-message-bubble.tsx +++ b/src/renderer/features/agents/ui/agent-user-message-bubble.tsx @@ -10,7 +10,7 @@ import { DialogTitle, } from "../../../components/ui/dialog" import { AgentImageItem } from "./agent-image-item" -import { RenderFileMentions, extractTextMentions, TextMentionBlocks } from "../mentions/render-file-mentions" +import { RenderFileMentions, RenderUserContent, extractTextMentions, TextMentionBlocks } from "../mentions/render-file-mentions" import { useSearchHighlight, useSearchQuery } from "../search" interface AgentUserMessageBubbleProps { @@ -234,7 +234,7 @@ export const AgentUserMessageBubble = memo(function AgentUserMessageBubble({ data-part-index={0} data-part-type="text" > - + {/* Show gradient only when collapsed and not searching in this message */} {showGradient && !hasCurrentSearchHighlight && (
@@ -282,7 +282,7 @@ export const AgentUserMessageBubble = memo(function AgentUserMessageBubble({ )}
- +
diff --git a/src/renderer/features/agents/utils/parse-user-code-blocks.ts b/src/renderer/features/agents/utils/parse-user-code-blocks.ts new file mode 100644 index 00000000..817ffc53 --- /dev/null +++ b/src/renderer/features/agents/utils/parse-user-code-blocks.ts @@ -0,0 +1,260 @@ +/** + * Shared parsing utility for detecting and splitting fenced code blocks + * in user message text. Used by both the paste handler and the editor + * for inline code block rendering. + */ + +export interface TextSegment { + type: "text" + content: string +} + +export interface CodeBlockSegment { + type: "code" + content: string + language: string +} + +export type MessageSegment = TextSegment | CodeBlockSegment + +/** + * Quick check for whether text contains any fenced code blocks. + * Requires a newline after the opening fence and before the closing fence + * to avoid false positives on inline triple backticks. + */ +export function hasCodeBlocks(text: string): boolean { + return /```[^\n]*\n[\s\S]*?\n```/.test(text) +} + +/** + * Parse text into alternating segments of plain text and fenced code blocks. + * + * Only matches triple-backtick fenced code blocks: + * ```language + * code here + * ``` + * + * Unclosed blocks are treated as plain text. + * The language identifier after ``` is captured (empty string if none). + */ +export function parseCodeBlocks(text: string): MessageSegment[] { + const segments: MessageSegment[] = [] + + // Match: ``` followed by optional language identifier, then newline, + // then content (non-greedy), then newline followed by closing ``` + // Language identifier allows alphanumeric, hyphens, plus, hash (e.g., c++, c#, objective-c) + const codeBlockRegex = /```([^\n]*)\n([\s\S]*?)\n```/g + + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = codeBlockRegex.exec(text)) !== null) { + // Add text before this code block + if (match.index > lastIndex) { + segments.push({ + type: "text", + content: text.slice(lastIndex, match.index), + }) + } + + // Add the code block + segments.push({ + type: "code", + language: (match[1] || "").trim(), + content: match[2], + }) + + lastIndex = match.index + match[0].length + } + + // Add remaining text after last code block + if (lastIndex < text.length) { + segments.push({ + type: "text", + content: text.slice(lastIndex), + }) + } + + // If no segments were created, return the whole text as a single text segment + if (segments.length === 0) { + segments.push({ type: "text", content: text }) + } + + return segments +} + +export interface CodeDetectionResult { + isCode: boolean + language: string +} + +/** + * Try to identify a specific programming language from text content. + * Returns a Shiki-compatible language ID or empty string if unknown. + * These are high-confidence patterns — any match means the text is definitely code. + */ +export function detectLanguage(text: string): string { + // PHP + if (/^<\?php/m.test(text)) return "php" + if (/\$\w+->/.test(text) && /;\s*$/m.test(text)) return "php" + if ( + /(?:public|private|protected)\s+function\s+\w+\s*\(/.test(text) && + /\$\w+/.test(text) + ) + return "php" + + // TypeScript / JavaScript + if (/^import\s+(?:.*\s+from\s+['"]|[{*])/m.test(text)) + return /<\w/.test(text) ? "tsx" : "typescript" + if (/^export\s+(?:default|function|class|const|type|interface|enum)\s/m.test(text)) + return "typescript" + + // Python + if (/^(?:def|class)\s+\w+[^:]*:\s*$/m.test(text)) return "python" + if (/^from\s+\S+\s+import\s/m.test(text)) return "python" + + // Java + if (/^import\s+java\./m.test(text) || /^package\s+[\w.]+;/m.test(text)) return "java" + if (/^public\s+class\s+\w+/m.test(text) && /;\s*$/m.test(text)) return "java" + + // C# + if (/^using\s+System/m.test(text) || /^using\s+\w+(\.\w+)+;/m.test(text)) return "csharp" + + // Ruby + if (/^require\s+['"]/.test(text) || /^require_relative\s+['"]/.test(text)) return "ruby" + if (/^\s*def\s+\w+/m.test(text) && /^\s*end\s*$/m.test(text)) return "ruby" + + // Swift + if (/^import\s+(?:Foundation|UIKit|SwiftUI)\b/m.test(text)) return "swift" + + // Kotlin + if (/^fun\s+\w+\s*\(/m.test(text) && /:\s*\w+/m.test(text)) return "kotlin" + if (/^data\s+class\s+\w+/m.test(text)) return "kotlin" + + // Go + if (/^package\s+\w+/m.test(text) && /^func\s/m.test(text)) return "go" + + // Rust + if (/^(?:pub\s+)?fn\s+\w+/m.test(text) || /^use\s+\w+::/m.test(text)) return "rust" + + // C/C++ + if (/^#include\s+[<"]/m.test(text)) return "cpp" + + // SQL + if ( + /^\s*(?:SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM|CREATE\s+TABLE|ALTER\s+TABLE)\s/im.test( + text, + ) + ) + return "sql" + + // Shell + if (/^#!/.test(text)) return "bash" + + // HTML + if (/^ l.trim()) + .filter((l) => /^\s*[\w.-]+:\s/.test(l)) + if (yamlLines.length >= 3 && !/[;{}()]/.test(text)) return "yaml" + + // CSS + if (/^[.#@]\w[^{]*\{/m.test(text) && /:\s*.+;/m.test(text)) return "css" + + // Dockerfile + if ( + /^FROM\s+\S+/m.test(text) && + /^(?:RUN|COPY|CMD|ENTRYPOINT|WORKDIR|ENV|EXPOSE)\s/m.test(text) + ) + return "dockerfile" + + return "" +} + +/** + * Structural analysis: detect whether text has the shape of code + * regardless of what language it's written in. + * + * Checks indentation patterns, special character density, line-ending + * punctuation, and absence of prose-like sentence structure. + */ +function hasCodeStructure(text: string, nonEmpty: string[]): boolean { + let score = 0 + + // 1. Consistent indentation — 30%+ of non-empty lines start with 2+ spaces or tab + const indented = nonEmpty.filter((l) => /^[\t ]{2,}/.test(l)) + if (indented.length >= nonEmpty.length * 0.3) score += 2 + + // 2. Code-like line endings — 30%+ end with ; { } ) , + const codeEndings = nonEmpty.filter((l) => /[;{},)]\s*$/.test(l.trim())) + if (codeEndings.length >= nonEmpty.length * 0.3) score += 2 + + // 3. Special character density > 5% of total characters + const specials = (text.match(/[{}[\]();=<>!&|+\-*/%^~]/g) || []).length + if (specials / text.length > 0.05) score += 2 + + // 4. Lacks prose — few lines look like natural-language sentences + const prose = nonEmpty.filter((l) => /^[A-Z][^;{}()\[\]]*[.!?]\s*$/.test(l.trim())) + if (prose.length <= nonEmpty.length * 0.15) score += 1 + + // 5. Balanced delimiters (roughly equal opens and closes) + const opens = (text.match(/[({[]/g) || []).length + const closes = (text.match(/[)}\]]/g) || []).length + if ( + opens >= 2 && + closes >= 2 && + Math.abs(opens - closes) <= Math.max(opens, closes) * 0.5 + ) + score += 1 + + // Need 5+ (out of 8 possible) to classify as code + return score >= 5 +} + +/** + * Detect whether pasted text is code. + * + * Uses a two-pass approach: + * 1. Language-specific patterns — high confidence, also identifies language + * 2. Structural analysis — catches any language without explicit patterns + * + * For the structural fallback the language is left empty (Shiki uses plaintext). + */ +export function looksLikeCode(text: string): CodeDetectionResult { + // Skip detection for very short or very large text + if (text.length > 10_000 || text.length < 10) return { isCode: false, language: "" } + + const lines = text.split("\n") + if (lines.length < 3) return { isCode: false, language: "" } + + const trimmed = text.trim() + const nonEmpty = lines.filter((l) => l.trim()) + if (nonEmpty.length < 2) return { isCode: false, language: "" } + + // Pass 1: language-specific patterns (strong signal → definitely code) + const language = detectLanguage(trimmed) + if (language) return { isCode: true, language } + + // Pass 2: structural analysis (language-agnostic) + if (hasCodeStructure(trimmed, nonEmpty)) { + return { isCode: true, language: "" } + } + + return { isCode: false, language: "" } +} diff --git a/src/renderer/features/agents/utils/paste-text.ts b/src/renderer/features/agents/utils/paste-text.ts index 67d0d534..23e0c363 100644 --- a/src/renderer/features/agents/utils/paste-text.ts +++ b/src/renderer/features/agents/utils/paste-text.ts @@ -1,4 +1,11 @@ import { toast } from "sonner" +import { + hasCodeBlocks, + parseCodeBlocks, + looksLikeCode, + detectLanguage, +} from "./parse-user-code-blocks" +import { createCodeBlockNode } from "../mentions/agents-mentions-editor" // Threshold for auto-converting large pasted text to a file (5KB) // Text larger than this will be saved as a file attachment instead of pasted inline @@ -65,6 +72,94 @@ export function insertTextAtCursor(text: string, editableElement: Element): void document.execCommand("insertText", false, textToInsert) } +/** + * Insert a code block element at the current cursor position, or append + * to the editable element if no selection is available. + */ +function insertCodeBlockAtCursor( + editableElement: Element, + language: string, + content: string, +): void { + const codeBlockEl = createCodeBlockNode(language, content) + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + range.deleteContents() + range.insertNode(codeBlockEl) + + const newRange = document.createRange() + newRange.setStartAfter(codeBlockEl) + newRange.collapse(true) + sel.removeAllRanges() + sel.addRange(newRange) + } else { + editableElement.appendChild(codeBlockEl) + } +} + +/** + * Check if clipboard HTML indicates text was copied from a code editor + * (VS Code, IntelliJ, Sublime, etc.) by looking for monospace fonts and + * syntax-highlighted spans in the HTML representation. + */ +function isPastedFromCodeEditor(clipboardData: DataTransfer): boolean { + const html = clipboardData.getData("text/html") + if (!html) return false + + // VS Code specific data attributes + if (/data-vscode/i.test(html)) return true + + // Monospace font-family used by code editors + const hasMonospaceFont = + /font-family:[^;]*(?:monospace|Consolas|Menlo|Monaco|Courier|SFMono|"Fira Code"|"JetBrains Mono"|"Source Code Pro"|Inconsolata)/i.test( + html, + ) + + // Multiple colored spans = syntax highlighting + const coloredSpans = (html.match(/]*style="[^"]*color:/g) || []).length + + return hasMonospaceFont && coloredSpans >= 2 +} + +/** + * Check if the cursor is positioned right after an opening code fence (```lang). + * If found, removes the fence text from the DOM and returns the language. + * Returns null if no opening fence is found before the cursor. + * + * This handles the case where a user types ```php then pastes code — + * we consume the fence and use its language for the code block. + */ +function consumeOpenFence(editableElement: Element): string | null { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return null + const range = sel.getRangeAt(0) + if (!range.collapsed) return null + + // Get all text from start of editor to cursor + const preRange = document.createRange() + preRange.selectNodeContents(editableElement) + preRange.setEnd(range.startContainer, range.startOffset) + const textBefore = preRange.toString() + + // Check for opening fence at end: ``` with optional language and trailing whitespace/newline + const fenceMatch = textBefore.match(/```(\w*)[\t ]*\n?$/) + if (!fenceMatch) return null + + const language = fenceMatch[1] || "" + const charsToDelete = fenceMatch[0].length + + // Delete the fence by extending selection backwards and deleting + for (let i = 0; i < charsToDelete; i++) { + sel.modify("extend", "backward", "character") + } + if (!sel.isCollapsed) { + document.execCommand("delete", false) + } + + return language +} + /** * Handle paste event for contentEditable elements. * Extracts images and passes them to handleAddAttachments. @@ -104,7 +199,80 @@ export function handlePasteEvent( const target = e.currentTarget as HTMLElement const editableElement = target.closest('[contenteditable="true"]') || target + + // Check if the user already typed an opening fence (```lang) before pasting. + // If so, consume the fence and always create a code block with that language. + const fenceLanguage = consumeOpenFence(editableElement) + if (fenceLanguage !== null) { + const language = fenceLanguage || detectLanguage(text) + insertCodeBlockAtCursor(editableElement, language, text) + editableElement.dispatchEvent(new Event("input", { bubbles: true })) + return + } + + // Check for fenced code blocks in pasted text + if (hasCodeBlocks(text)) { + const segments = parseCodeBlocks(text) + insertSegmentsAtCursor(segments, editableElement, addPastedText) + return + } + + // Auto-detect code: first check if pasted from a code editor (VS Code, etc.), + // then fall back to heuristic text analysis + const fromEditor = isPastedFromCodeEditor(e.clipboardData) + const codeDetection = fromEditor + ? { isCode: true, language: detectLanguage(text) } + : looksLikeCode(text) + if (codeDetection.isCode) { + // Large auto-detected code: convert to file attachment if possible + if (text.length > LARGE_PASTE_THRESHOLD && addPastedText) { + addPastedText(text) + return + } + insertCodeBlockAtCursor(editableElement, codeDetection.language, text) + editableElement.dispatchEvent(new Event("input", { bubbles: true })) + return + } + insertTextAtCursor(text, editableElement) } } } + +/** + * Insert parsed segments (text + code blocks) at the current cursor position. + * Text segments are inserted via execCommand, code blocks are inserted as DOM elements. + */ +function insertSegmentsAtCursor( + segments: ReturnType, + editableElement: Element, + addPastedText?: AddPastedTextFn, +): void { + let insertedCodeBlock = false + + for (const segment of segments) { + if (segment.type === "text") { + if (segment.content) { + insertTextAtCursor(segment.content, editableElement) + } + } else { + // Code block segment + // If the code block is too large, convert to file attachment + if (segment.content.length > LARGE_PASTE_THRESHOLD && addPastedText) { + const fileContent = "```" + segment.language + "\n" + segment.content + "\n```" + addPastedText(fileContent) + continue + } + + // Insert code block element at cursor position + insertCodeBlockAtCursor(editableElement, segment.language, segment.content) + insertedCodeBlock = true + } + } + + // Only dispatch manual input event if code block elements were inserted + // (text segments already fire input events via execCommand) + if (insertedCodeBlock) { + editableElement.dispatchEvent(new Event("input", { bubbles: true })) + } +} diff --git a/src/renderer/lib/themes/shiki-theme-loader.ts b/src/renderer/lib/themes/shiki-theme-loader.ts index 6c77e58b..b8edc73c 100644 --- a/src/renderer/lib/themes/shiki-theme-loader.ts +++ b/src/renderer/lib/themes/shiki-theme-loader.ts @@ -56,6 +56,35 @@ class LRUCache { const highlightCache = new LRUCache(HIGHLIGHT_CACHE_MAX_SIZE) +// Track in-flight language load promises to prevent concurrent duplicate loads +const pendingLanguageLoads = new Map>() + +// Shared theme-change observer: single MutationObserver shared by all consumers +type ThemeChangeListener = () => void +const themeChangeListeners = new Set() +let themeObserver: MutationObserver | null = null + +/** Subscribe to theme changes on document.documentElement. Returns an unsubscribe function. */ +export function onThemeChange(listener: ThemeChangeListener): () => void { + themeChangeListeners.add(listener) + if (!themeObserver) { + themeObserver = new MutationObserver(() => { + themeChangeListeners.forEach((fn) => fn()) + }) + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }) + } + return () => { + themeChangeListeners.delete(listener) + if (themeChangeListeners.size === 0 && themeObserver) { + themeObserver.disconnect() + themeObserver = null + } + } +} + /** * Languages supported by the highlighter */ @@ -266,10 +295,30 @@ export async function highlightCode( // Get the theme to use for highlighting const shikiTheme = getShikiThemeForHighlighting(themeId) - const loadedLangs = highlighter.getLoadedLanguages() - const lang = loadedLangs.includes(language as shiki.BundledLanguage) - ? (language as shiki.BundledLanguage) - : "plaintext" + // Resolve language: use loaded language, try dynamic loading, or fall back to plaintext + let lang: shiki.BundledLanguage | "plaintext" = "plaintext" + if (language && language !== "plaintext") { + const loadedLangs = highlighter.getLoadedLanguages() + if (loadedLangs.includes(language as shiki.BundledLanguage)) { + lang = language as shiki.BundledLanguage + } else { + // Try to load the language dynamically from Shiki's bundles. + // Use a shared promise to prevent concurrent duplicate loads for the same language. + let loadPromise = pendingLanguageLoads.get(language) + if (!loadPromise) { + loadPromise = highlighter + .loadLanguage(language as shiki.BundledLanguage) + .then(() => true) + .catch(() => false) + .finally(() => pendingLanguageLoads.delete(language)) + pendingLanguageLoads.set(language, loadPromise) + } + const loaded = await loadPromise + if (loaded) { + lang = language as shiki.BundledLanguage + } + } + } const html = highlighter.codeToHtml(code, { lang,