Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
373 changes: 6 additions & 367 deletions app/components/MessagePartHandler.tsx

Large diffs are not rendered by default.

199 changes: 185 additions & 14 deletions app/components/TerminalCodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from "react";
"use client";

import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Copy, Check, Download, WrapText, Terminal } from "lucide-react";
import ShikiHighlighter from "react-shiki";
import { codeToHtml } from "shiki";
import {
Tooltip,
TooltipTrigger,
Expand All @@ -17,6 +19,182 @@ interface TerminalCodeBlockProps {
isBackground?: boolean;
}

interface AnsiCodeBlockProps {
code: string;
isWrapped?: boolean;
isStreaming?: boolean;
theme?: string;
className?: string;
style?: React.CSSProperties;
delay?: number;
}

// Cache for rendered ANSI content to avoid re-rendering identical content
const ansiCache = new Map<string, string>();
const MAX_CACHE_SIZE = 100;

// Clean cache when it gets too large
const cleanCache = () => {
if (ansiCache.size >= MAX_CACHE_SIZE) {
const entries = Array.from(ansiCache.entries());
// Remove only the overflow count of oldest entries (FIFO)
const toRemove = Math.max(0, ansiCache.size - MAX_CACHE_SIZE + 1);
for (let i = 0; i < toRemove; i++) {
ansiCache.delete(entries[i][0]);
}
}
};

/**
* Optimized ANSI code renderer with streaming support
* Uses native Shiki codeToHtml with react-shiki patterns for performance
*
* Features:
* - Debounced rendering for streaming content (150ms delay, same as react-shiki)
* - Caching to avoid re-rendering identical content
* - Race condition protection for async renders
* - Memory management with cache cleanup
* - Proper HTML escaping for fallback content
* - Follows react-shiki performance patterns
*/
const AnsiCodeBlock = ({
code,
isWrapped,
isStreaming = false,
theme = "houston",
className,
style,
delay: customDelay,
}: AnsiCodeBlockProps) => {
const [htmlContent, setHtmlContent] = useState<string>("");
const [isRendering, setIsRendering] = useState(false);
const renderTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastRenderedCodeRef = useRef<string>("");
const cacheKeyRef = useRef<string>("");

// Debounce rendering for streaming content
const debouncedRender = useCallback(
async (codeToRender: string) => {
// Clear any pending render
if (renderTimeoutRef.current) {
clearTimeout(renderTimeoutRef.current);
}

// For streaming, debounce rapid updates (same as react-shiki default)
const delay = customDelay ?? (isStreaming ? 150 : 0);

renderTimeoutRef.current = setTimeout(async () => {
// Skip if we've already rendered this exact content
if (lastRenderedCodeRef.current === codeToRender) {
return;
}

// Create cache key including theme for proper caching
const cacheKey = `${codeToRender}-${isWrapped}-${theme}`;
cacheKeyRef.current = cacheKey;

// Check cache first
if (ansiCache.has(cacheKey)) {
setHtmlContent(ansiCache.get(cacheKey)!);
lastRenderedCodeRef.current = codeToRender;
return;
}

setIsRendering(true);

try {
const html = await codeToHtml(codeToRender, {
lang: "ansi",
theme: theme,
});

// Only update if this is still the current render request
if (cacheKeyRef.current === cacheKey) {
setHtmlContent(html);
cleanCache();
ansiCache.set(cacheKey, html);
lastRenderedCodeRef.current = codeToRender;
}
Comment on lines +105 to +117
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Sanitize HTML before injecting (XSS risk with dangerouslySetInnerHTML)

Shiki output and the fallback HTML should be sanitized before setting state and caching.

@@
-        try {
-          const html = await codeToHtml(codeToRender, {
+        try {
+          const html = await codeToHtml(codeToRender, {
             lang: "ansi",
             theme: theme,
           });
 
           // Only update if this is still the current render request
           if (cacheKeyRef.current === cacheKey) {
-            setHtmlContent(html);
-            cleanCache();
-            ansiCache.set(cacheKey, html);
+            const sanitized = DOMPurify.sanitize(html);
+            cleanCache();
+            ansiCache.set(cacheKey, sanitized);
+            setHtmlContent(sanitized);
             lastRenderedCodeRef.current = codeToRender;
           }
         } catch (error) {
@@
-          const fallbackHtml = `<pre><code>${escapedCode}</code></pre>`;
+          const fallbackHtml = `<pre><code>${escapedCode}</code></pre>`;
 
           if (cacheKeyRef.current === cacheKey) {
-            setHtmlContent(fallbackHtml);
-            cleanCache();
-            ansiCache.set(cacheKey, fallbackHtml);
+            const sanitized = DOMPurify.sanitize(fallbackHtml);
+            cleanCache();
+            ansiCache.set(cacheKey, sanitized);
+            setHtmlContent(sanitized);
             lastRenderedCodeRef.current = codeToRender;
           }
         } finally {
           setIsRendering(false);
         }
@@
   return (
     <div
       className={containerClassName}
       style={style}
       dangerouslySetInnerHTML={{ __html: htmlContent }}
     />
   );

Outside-range import to add at the top:

import DOMPurify from "dompurify";

Also applies to: 121-137, 189-194

🤖 Prompt for AI Agents
In app/components/TerminalCodeBlock.tsx around lines 105-117 (and also apply
same changes to 121-137 and 189-194), the generated HTML from shiki/fallback is
being stored and injected without sanitization; import DOMPurify at the top of
the file and run DOMPurify.sanitize on the html string before calling
setHtmlContent and before saving to ansiCache to remove any malicious scripts or
attributes, ensuring you sanitize both the primary and fallback HTML paths and
cache/store the sanitized result only.

} catch (error) {
console.error("Failed to render ANSI code:", error);
// Fallback to plain text with proper escaping
const escapedCode = codeToRender.replace(/[&<>"']/g, (char) => {
const entities: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return entities[char];
});
const fallbackHtml = `<pre><code>${escapedCode}</code></pre>`;

if (cacheKeyRef.current === cacheKey) {
setHtmlContent(fallbackHtml);
cleanCache();
ansiCache.set(cacheKey, fallbackHtml);
lastRenderedCodeRef.current = codeToRender;
}
} finally {
setIsRendering(false);
}
}, delay);
},
[isStreaming, isWrapped, theme, customDelay],
);

useEffect(() => {
if (code) {
debouncedRender(code);
} else {
setHtmlContent("");
lastRenderedCodeRef.current = "";
}

// Cleanup timeout on unmount or code change
return () => {
if (renderTimeoutRef.current) {
clearTimeout(renderTimeoutRef.current);
}
};
}, [code, debouncedRender]);

// Memoize the className to prevent unnecessary re-calculations (react-shiki pattern)
const containerClassName = useMemo(() => {
const baseClasses = `shiki not-prose relative bg-card text-sm font-[450] text-card-foreground [&_pre]:!bg-transparent [&_pre]:px-[1em] [&_pre]:py-[1em] [&_pre]:rounded-none [&_pre]:m-0 ${
isWrapped
? "[&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:overflow-visible"
: "[&_pre]:overflow-x-auto [&_pre]:max-w-full"
}`;
return className ? `${baseClasses} ${className}` : baseClasses;
}, [isWrapped, className]);

// Show loading state for initial render or when switching between very different content
if (!htmlContent && (isRendering || code)) {
return (
<div className="px-4 py-4 text-muted-foreground">
<ShimmerText>
{isStreaming ? "Processing output..." : "Rendering output..."}
</ShimmerText>
</div>
);
}

// Show empty state
if (!htmlContent && !code) {
return null;
}

return (
<div
className={containerClassName}
style={style}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
};

export const TerminalCodeBlock = ({
command,
output,
Expand Down Expand Up @@ -179,20 +357,13 @@ export const TerminalCodeBlock = ({
<ShimmerText>Executing command</ShimmerText>
</div>
) : (
<ShikiHighlighter
language="ansi"
<AnsiCodeBlock
code={output || ""}
isWrapped={isWrapped}
isStreaming={status === "streaming" || isExecuting}
theme="houston"
delay={150}
addDefaultStyles={false}
showLanguage={false}
className={`shiki not-prose relative bg-card text-sm font-mono text-card-foreground [&_pre]:!bg-transparent [&_pre]:px-[1em] [&_pre]:py-[1em] [&_pre]:rounded-none [&_pre]:m-0 ${
isWrapped
? "[&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:overflow-visible"
: "[&_pre]:overflow-x-auto [&_pre]:max-w-full"
}`}
>
{output || ""}
</ShikiHighlighter>
/>
)}
</div>
</>
Expand Down
Loading